From f9b07dad24d6c205562b06bad8281c86953ed4a1 Mon Sep 17 00:00:00 2001 From: Matchu Date: Mon, 19 Apr 2021 03:56:51 -0700 Subject: [PATCH] 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! --- .../WardrobePage/Basic outfit state.spec.js | 84 ++++------- .../WardrobePage/Outfit saving.spec.js | 20 ++- .../Basic outfit state.spec.js.snap | 16 ++ cypress/integration/WardrobePage/page.js | 30 ++++ src/app/WardrobePage/ItemsPanel.js | 142 +++++++++++++++--- src/server/types/Outfit.js | 106 +++++++++++++ 6 files changed, 325 insertions(+), 73 deletions(-) create mode 100644 cypress/integration/WardrobePage/page.js diff --git a/cypress/integration/WardrobePage/Basic outfit state.spec.js b/cypress/integration/WardrobePage/Basic outfit state.spec.js index 4d0a124..3a0eba2 100644 --- a/cypress/integration/WardrobePage/Basic outfit state.spec.js +++ b/cypress/integration/WardrobePage/Basic outfit state.spec.js @@ -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); -} diff --git a/cypress/integration/WardrobePage/Outfit saving.spec.js b/cypress/integration/WardrobePage/Outfit saving.spec.js index de79507..7e1c6f5 100644 --- a/cypress/integration/WardrobePage/Outfit saving.spec.js +++ b/cypress/integration/WardrobePage/Outfit saving.spec.js @@ -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); }); }); diff --git a/cypress/integration/WardrobePage/__snapshots__/Basic outfit state.spec.js.snap b/cypress/integration/WardrobePage/__snapshots__/Basic outfit state.spec.js.snap index 22c4aeb..bdc3512 100644 --- a/cypress/integration/WardrobePage/__snapshots__/Basic outfit state.spec.js.snap +++ b/cypress/integration/WardrobePage/__snapshots__/Basic outfit state.spec.js.snap @@ -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" +}; diff --git a/cypress/integration/WardrobePage/page.js b/cypress/integration/WardrobePage/page.js new file mode 100644 index 0000000..42dc938 --- /dev/null +++ b/cypress/integration/WardrobePage/page.js @@ -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); +} diff --git a/src/app/WardrobePage/ItemsPanel.js b/src/app/WardrobePage/ItemsPanel.js index e377957..ecba292 100644 --- a/src/app/WardrobePage/ItemsPanel.js +++ b/src/app/WardrobePage/ItemsPanel.js @@ -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 }) { - {isLoggedIn && ( + {canSaveOutfit && ( <> - - - + )} diff --git a/src/server/types/Outfit.js b/src/server/types/Outfit.js index 2328ef6..3fcd047 100644 --- a/src/server/types/Outfit.js +++ b/src/server/types/Outfit.js @@ -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 };