Can save new outfits w/o items
Just a basic e2e starting point! Simple logic, with simple gates to prevent saving outfits we're not ready for. Safe to ship, despite being very incomplete!
This commit is contained in:
parent
531ca3c22f
commit
f9b07dad24
6 changed files with 325 additions and 73 deletions
|
@ -1,3 +1,5 @@
|
|||
import * as page from "./page";
|
||||
|
||||
// Give network requests a bit of breathing room!
|
||||
const networkTimeout = { timeout: 12000 };
|
||||
|
||||
|
@ -5,105 +7,81 @@ describe("WardrobePage: Basic outfit state", () => {
|
|||
it("Initialize simple outfit from URL", () => {
|
||||
cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
|
||||
|
||||
getSpeciesSelect(networkTimeout)
|
||||
page
|
||||
.getSpeciesSelect(networkTimeout)
|
||||
.find(":selected")
|
||||
.should("have.text", "Acara");
|
||||
getColorSelect().find(":selected").should("have.text", "Blue");
|
||||
page.getColorSelect().find(":selected").should("have.text", "Blue");
|
||||
cy.location().toMatchSnapshot();
|
||||
|
||||
cy.contains("A Warm Winters Night Background", networkTimeout).should(
|
||||
"exist"
|
||||
);
|
||||
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
});
|
||||
|
||||
it("Changes species and color", () => {
|
||||
cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
|
||||
|
||||
getSpeciesSelect(networkTimeout)
|
||||
page
|
||||
.getSpeciesSelect(networkTimeout)
|
||||
.find(":selected")
|
||||
.should("have.text", "Acara");
|
||||
getColorSelect().find(":selected").should("have.text", "Blue");
|
||||
page.getColorSelect().find(":selected").should("have.text", "Blue");
|
||||
cy.location().toMatchSnapshot();
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
|
||||
getSpeciesSelect().select("Aisha");
|
||||
page.getSpeciesSelect().select("Aisha");
|
||||
|
||||
getSpeciesSelect().find(":selected").should("have.text", "Aisha");
|
||||
getColorSelect().find(":selected").should("have.text", "Blue");
|
||||
page.getSpeciesSelect().find(":selected").should("have.text", "Aisha");
|
||||
page.getColorSelect().find(":selected").should("have.text", "Blue");
|
||||
cy.location().toMatchSnapshot();
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
|
||||
getColorSelect().select("Red");
|
||||
page.getColorSelect().select("Red");
|
||||
|
||||
getSpeciesSelect().find(":selected").should("have.text", "Aisha");
|
||||
getColorSelect().find(":selected").should("have.text", "Red");
|
||||
page.getSpeciesSelect().find(":selected").should("have.text", "Aisha");
|
||||
page.getColorSelect().find(":selected").should("have.text", "Red");
|
||||
cy.location().toMatchSnapshot();
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
});
|
||||
|
||||
it.only("Changes pose", () => {
|
||||
it("Changes pose", () => {
|
||||
cy.visit("/outfits/new?species=1&color=8&pose=HAPPY_FEM");
|
||||
|
||||
getPosePickerButton(networkTimeout).click();
|
||||
getPosePickerOption("Happy and Feminine").should("be.checked");
|
||||
page.getPosePickerButton(networkTimeout).click();
|
||||
page.getPosePickerOption("Happy and Feminine").should("be.checked");
|
||||
cy.location().toMatchSnapshot();
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
|
||||
getPosePickerOption("Sad and Masculine").check({ force: true });
|
||||
getPosePickerOption("Sad and Masculine").should("be.checked");
|
||||
page.getPosePickerOption("Sad and Masculine").check({ force: true });
|
||||
page.getPosePickerOption("Sad and Masculine").should("be.checked");
|
||||
cy.location().toMatchSnapshot();
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
});
|
||||
|
||||
it("Toggles item", () => {
|
||||
cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
|
||||
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
cy.location().toMatchSnapshot();
|
||||
|
||||
cy.contains("A Warm Winters Night Background").click();
|
||||
|
||||
getOutfitPreview().toMatchImageSnapshot();
|
||||
page.getOutfitPreview().toMatchImageSnapshot();
|
||||
cy.location().toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("Renames outfit", () => {
|
||||
cy.visit("/outfits/new?name=My+outfit&species=1&color=8");
|
||||
|
||||
getOutfitName(networkTimeout).should("have.text", "My outfit");
|
||||
page.getOutfitName(networkTimeout).should("have.text", "My outfit");
|
||||
|
||||
getOutfitName().click().type("Awesome outfit{enter}");
|
||||
page.getOutfitName().click();
|
||||
cy.focused().type("Awesome outfit{enter}");
|
||||
|
||||
getOutfitName().should("have.text", "Awesome outfit");
|
||||
page.getOutfitName().should("have.text", "Awesome outfit");
|
||||
cy.location().toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
function getSpeciesSelect(options) {
|
||||
return cy.get("[data-test-id=wardrobe-species-picker]", options);
|
||||
}
|
||||
|
||||
function getColorSelect(options) {
|
||||
return cy.get("[data-test-id=wardrobe-color-picker]", options);
|
||||
}
|
||||
|
||||
function getPosePickerButton(options) {
|
||||
return cy.get("[data-test-id=wardrobe-pose-picker]", options);
|
||||
}
|
||||
|
||||
function getPosePickerOption(label, options) {
|
||||
return cy.get(`input[aria-label="${CSS.escape(label)}"]`, options);
|
||||
}
|
||||
|
||||
function getOutfitPreview() {
|
||||
return cy.get("[data-test-id=wardrobe-outfit-preview]:not([data-loading])", {
|
||||
// A bit of an extra-long timeout, to await both server data and image data
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
function getOutfitName(options) {
|
||||
return cy.get("[data-test-id=outfit-name]", options);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,24 @@
|
|||
import * as page from "./page";
|
||||
|
||||
describe("WardrobePage: Outfit saving", () => {
|
||||
it("logs in", () => {
|
||||
cy.logInAs("dti-test");
|
||||
cy.visit("/");
|
||||
cy.visit("/outfits/new?species=1&color=8");
|
||||
|
||||
// Give the outfit a unique timestamped name
|
||||
const outfitName = `Cypress Test Outfit: ${new Date().toISOString()}`;
|
||||
page.getOutfitName({ timeout: 12000 }).click();
|
||||
cy.focused().type(outfitName + "{enter}");
|
||||
|
||||
// Save the outfit
|
||||
page.getSaveOutfitButton().click().should("have.attr", "data-loading");
|
||||
|
||||
// Wait for the outfit to stop saving, and check that it redirected and
|
||||
// still shows the correct outfit name.
|
||||
page
|
||||
.getSaveOutfitButton({ timeout: 12000 })
|
||||
.should("not.have.attr", "data-loading");
|
||||
cy.location("pathname").should("match", /^\/outfits\/[0-9]+$/);
|
||||
page.getOutfitName().should("have.text", outfitName);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -125,3 +125,19 @@ exports[`WardrobePage: Basic outfit state > Changes pose #1`] =
|
|||
"search": "?name=&species=1&color=8&pose=SAD_MASC",
|
||||
"superDomain": "localhost"
|
||||
};
|
||||
|
||||
exports[`WardrobePage: Basic outfit state > Renames outfit #0`] =
|
||||
{
|
||||
"auth": "",
|
||||
"hash": "",
|
||||
"host": "localhost:3000",
|
||||
"hostname": "localhost",
|
||||
"href": "http://localhost:3000/outfits/new?name=Awesome+outfit&species=1&color=8&pose=HAPPY_FEM",
|
||||
"origin": "http://localhost:3000",
|
||||
"originPolicy": "http://localhost:3000",
|
||||
"pathname": "/outfits/new",
|
||||
"port": "3000",
|
||||
"protocol": "http:",
|
||||
"search": "?name=Awesome+outfit&species=1&color=8&pose=HAPPY_FEM",
|
||||
"superDomain": "localhost"
|
||||
};
|
||||
|
|
30
cypress/integration/WardrobePage/page.js
Normal file
30
cypress/integration/WardrobePage/page.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
export function getSpeciesSelect(options) {
|
||||
return cy.get("[data-test-id=wardrobe-species-picker]", options);
|
||||
}
|
||||
|
||||
export function getColorSelect(options) {
|
||||
return cy.get("[data-test-id=wardrobe-color-picker]", options);
|
||||
}
|
||||
|
||||
export function getPosePickerButton(options) {
|
||||
return cy.get("[data-test-id=wardrobe-pose-picker]", options);
|
||||
}
|
||||
|
||||
export function getPosePickerOption(label, options) {
|
||||
return cy.get(`input[aria-label="${CSS.escape(label)}"]`, options);
|
||||
}
|
||||
|
||||
export function getOutfitPreview() {
|
||||
return cy.get("[data-test-id=wardrobe-outfit-preview]:not([data-loading])", {
|
||||
// A bit of an extra-long timeout, to await both server data and image data
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOutfitName(options) {
|
||||
return cy.get("[data-test-id=outfit-name]", options);
|
||||
}
|
||||
|
||||
export function getSaveOutfitButton(options) {
|
||||
return cy.get("[data-test-id=wardrobe-save-outfit-button]", options);
|
||||
}
|
|
@ -16,9 +16,11 @@ import {
|
|||
MenuItem,
|
||||
Portal,
|
||||
Button,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { EditIcon, QuestionIcon } from "@chakra-ui/icons";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { Delay, Heading1, Heading2 } from "../util";
|
||||
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
||||
|
@ -26,6 +28,8 @@ import { BiRename } from "react-icons/bi";
|
|||
import { IoCloudUploadOutline } from "react-icons/io5";
|
||||
import { MdMoreVert } from "react-icons/md";
|
||||
import useCurrentUser from "../components/useCurrentUser";
|
||||
import gql from "graphql-tag";
|
||||
import { useMutation } from "@apollo/client";
|
||||
|
||||
/**
|
||||
* ItemsPanel shows the items in the current outfit, and lets the user toggle
|
||||
|
@ -246,12 +250,111 @@ function ItemZoneGroupSkeleton({ itemCount }) {
|
|||
);
|
||||
}
|
||||
|
||||
function useOutfitSaving(outfitState) {
|
||||
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
||||
const history = useHistory();
|
||||
const toast = useToast();
|
||||
|
||||
const isSaved = outfitState.id;
|
||||
|
||||
// Only logged-in users can save outfits - and they can only save new outfits,
|
||||
// or outfits they created.
|
||||
const canSaveOutfit =
|
||||
isLoggedIn &&
|
||||
(!isSaved || outfitState.creator?.id === currentUserId) &&
|
||||
// TODO: Add support for outfits with items
|
||||
outfitState.wornItemIds.length === 0 &&
|
||||
outfitState.closetedItemIds.length === 0;
|
||||
|
||||
const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation(
|
||||
gql`
|
||||
mutation UseOutfitSaving_SaveOutfit(
|
||||
$id: ID # Optional, is null when saving new outfits.
|
||||
$name: String # Optional, server may fill in a placeholder.
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$pose: Pose!
|
||||
$wornItemIds: [ID!]!
|
||||
$closetedItemIds: [ID!]!
|
||||
) {
|
||||
outfit: saveOutfit(
|
||||
id: $id
|
||||
name: $name
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: $pose
|
||||
wornItemIds: $wornItemIds
|
||||
closetedItemIds: $closetedItemIds
|
||||
) {
|
||||
id
|
||||
name
|
||||
petAppearance {
|
||||
id
|
||||
species {
|
||||
id
|
||||
}
|
||||
color {
|
||||
id
|
||||
}
|
||||
pose
|
||||
}
|
||||
wornItems {
|
||||
id
|
||||
}
|
||||
closetedItems {
|
||||
id
|
||||
}
|
||||
creator {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
id: outfitState.id, // Optional, is null when saving new outfits
|
||||
name: outfitState.name, // Optional, server may fill in a placeholder
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
wornItemIds: outfitState.wornItemIds,
|
||||
closetedItemIds: outfitState.closetedItemIds,
|
||||
},
|
||||
context: { sendAuth: true },
|
||||
}
|
||||
);
|
||||
|
||||
const saveOutfit = React.useCallback(() => {
|
||||
sendSaveOutfitMutation()
|
||||
.then(({ data: { outfit } }) => {
|
||||
// Navigate to the new saved outfit URL. Our Apollo cache should pick
|
||||
// up the data from this mutation response, and combine it with the
|
||||
// existing cached data, to make this smooth without any loading UI.
|
||||
history.push(`/outfits/${outfit.id}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Sorry, there was an error saving this outfit!",
|
||||
description: "Maybe check your connection and try again.",
|
||||
});
|
||||
});
|
||||
}, [sendSaveOutfitMutation, history, toast]);
|
||||
|
||||
return {
|
||||
canSaveOutfit,
|
||||
isSaving,
|
||||
saveOutfit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OutfitHeading is an editable outfit name, as a big pretty page heading!
|
||||
* It also contains the outfit menu, for saving etc.
|
||||
*/
|
||||
function OutfitHeading({ outfitState, dispatchToOutfit }) {
|
||||
const { isLoggedIn } = useCurrentUser();
|
||||
const { canSaveOutfit, isSaving, saveOutfit } = useOutfitSaving(outfitState);
|
||||
|
||||
return (
|
||||
// The Editable wraps everything, including the menu, because the menu has
|
||||
|
@ -277,25 +380,26 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
|
|||
</Box>
|
||||
</Box>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
{isLoggedIn && (
|
||||
{canSaveOutfit && (
|
||||
<>
|
||||
<Tooltip label="Coming soon!" shouldWrapChildren>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isDisabled
|
||||
leftIcon={
|
||||
<Box
|
||||
// Adjust the visual balance toward the cloud
|
||||
marginBottom="-2px"
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving…"
|
||||
leftIcon={
|
||||
<Box
|
||||
// Adjust the visual balance toward the cloud
|
||||
marginBottom="-2px"
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
</Box>
|
||||
}
|
||||
onClick={saveOutfit}
|
||||
data-test-id="wardrobe-save-outfit-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Box width="2" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { gql } from "apollo-server";
|
||||
import { getPoseFromPetState } from "../util";
|
||||
|
||||
const typeDefs = gql`
|
||||
type Outfit {
|
||||
|
@ -17,6 +18,18 @@ const typeDefs = gql`
|
|||
extend type Query {
|
||||
outfit(id: ID!): Outfit
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
saveOutfit(
|
||||
id: ID # Optional, is null when saving new outfits.
|
||||
name: String # Optional, server may fill in a placeholder.
|
||||
speciesId: ID!
|
||||
colorId: ID!
|
||||
pose: Pose!
|
||||
wornItemIds: [ID!]!
|
||||
closetedItemIds: [ID!]!
|
||||
): Outfit!
|
||||
}
|
||||
`;
|
||||
|
||||
const resolvers = {
|
||||
|
@ -77,6 +90,7 @@ const resolvers = {
|
|||
return { id: outfit.userId };
|
||||
},
|
||||
},
|
||||
|
||||
Query: {
|
||||
outfit: async (_, { id }, { outfitLoader }) => {
|
||||
const outfit = await outfitLoader.load(id);
|
||||
|
@ -87,6 +101,98 @@ const resolvers = {
|
|||
return { id };
|
||||
},
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
saveOutfit: async (
|
||||
_,
|
||||
{
|
||||
id,
|
||||
name: rawName,
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
wornItemIds,
|
||||
closetedItemIds,
|
||||
},
|
||||
{
|
||||
currentUserId,
|
||||
db,
|
||||
petTypeBySpeciesAndColorLoader,
|
||||
petStatesForPetTypeLoader,
|
||||
}
|
||||
) => {
|
||||
if (!currentUserId) {
|
||||
throw new Error(
|
||||
"saveOutfit requires login for now. This might change!"
|
||||
);
|
||||
}
|
||||
|
||||
if (id) {
|
||||
throw new Error("TODO: Add support for updating existing outfits");
|
||||
}
|
||||
|
||||
if (wornItemIds.length > 0 || closetedItemIds.length > 0) {
|
||||
throw new Error("TODO: Add support for outfits with items");
|
||||
}
|
||||
|
||||
// Get the base name of the provided name: trim it, and strip any "(1)"
|
||||
// suffixes.
|
||||
const baseName = rawName.replace(/\s*\([0-9]+\)\s*$/, "");
|
||||
const namePlaceholder = baseName.trim().replace(/_%/g, "\\$0") + "%";
|
||||
|
||||
// Then, look for outfits from this user with the same base name.
|
||||
const [outfitRows] = await db.query(
|
||||
`
|
||||
SELECT name FROM outfits WHERE user_id = ? AND name LIKE ?;
|
||||
`,
|
||||
[currentUserId, namePlaceholder]
|
||||
);
|
||||
const existingOutfitNames = new Set(
|
||||
outfitRows.map(({ name }) => name.trim())
|
||||
);
|
||||
|
||||
// Then, get the unique name to use for this outfit: try the base name
|
||||
// first, but, if it's taken, keep incrementing the "(1)" suffix until
|
||||
// it's not.
|
||||
let name = baseName;
|
||||
for (let i = 1; existingOutfitNames.has(name); i++) {
|
||||
name = `${baseName} (${i})`;
|
||||
}
|
||||
|
||||
// Next, get the petState corresponding to this species/color/pose.
|
||||
const petType = await petTypeBySpeciesAndColorLoader.load({
|
||||
speciesId,
|
||||
colorId,
|
||||
});
|
||||
if (!petType) {
|
||||
throw new Error(
|
||||
`could not find pet type for species=${speciesId}, color=${colorId}`
|
||||
);
|
||||
}
|
||||
// TODO: We could query for this more directly, instead of loading all
|
||||
// appearances 🤔
|
||||
const petStates = await petStatesForPetTypeLoader.load(petType.id);
|
||||
const petState = petStates.find((ps) => getPoseFromPetState(ps) === pose);
|
||||
if (!petState) {
|
||||
throw new Error(
|
||||
`could not find appearance for species=${speciesId}, color=${colorId}, pose=${pose}`
|
||||
);
|
||||
}
|
||||
|
||||
const [result] = await db.execute(
|
||||
`
|
||||
INSERT INTO outfits (name, pet_state_id, user_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP());
|
||||
`,
|
||||
[name, petState.id, currentUserId]
|
||||
);
|
||||
|
||||
const newOutfitId = String(result.insertId);
|
||||
console.log(`Saved outfit ${newOutfitId}`);
|
||||
|
||||
return { id: newOutfitId };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { typeDefs, resolvers };
|
||||
|
|
Loading…
Reference in a new issue