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 };