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:
Emi Matchu 2021-04-19 03:56:51 -07:00
parent 531ca3c22f
commit f9b07dad24
6 changed files with 325 additions and 73 deletions

View file

@ -1,3 +1,5 @@
import * as page from "./page";
// Give network requests a bit of breathing room! // Give network requests a bit of breathing room!
const networkTimeout = { timeout: 12000 }; const networkTimeout = { timeout: 12000 };
@ -5,105 +7,81 @@ describe("WardrobePage: Basic outfit state", () => {
it("Initialize simple outfit from URL", () => { it("Initialize simple outfit from URL", () => {
cy.visit("/outfits/new?species=1&color=8&objects[]=76789"); cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
getSpeciesSelect(networkTimeout) page
.getSpeciesSelect(networkTimeout)
.find(":selected") .find(":selected")
.should("have.text", "Acara"); .should("have.text", "Acara");
getColorSelect().find(":selected").should("have.text", "Blue"); page.getColorSelect().find(":selected").should("have.text", "Blue");
cy.location().toMatchSnapshot(); cy.location().toMatchSnapshot();
cy.contains("A Warm Winters Night Background", networkTimeout).should( cy.contains("A Warm Winters Night Background", networkTimeout).should(
"exist" "exist"
); );
getOutfitPreview().toMatchImageSnapshot(); page.getOutfitPreview().toMatchImageSnapshot();
}); });
it("Changes species and color", () => { it("Changes species and color", () => {
cy.visit("/outfits/new?species=1&color=8&objects[]=76789"); cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
getSpeciesSelect(networkTimeout) page
.getSpeciesSelect(networkTimeout)
.find(":selected") .find(":selected")
.should("have.text", "Acara"); .should("have.text", "Acara");
getColorSelect().find(":selected").should("have.text", "Blue"); page.getColorSelect().find(":selected").should("have.text", "Blue");
cy.location().toMatchSnapshot(); cy.location().toMatchSnapshot();
getOutfitPreview().toMatchImageSnapshot(); page.getOutfitPreview().toMatchImageSnapshot();
getSpeciesSelect().select("Aisha"); page.getSpeciesSelect().select("Aisha");
getSpeciesSelect().find(":selected").should("have.text", "Aisha"); page.getSpeciesSelect().find(":selected").should("have.text", "Aisha");
getColorSelect().find(":selected").should("have.text", "Blue"); page.getColorSelect().find(":selected").should("have.text", "Blue");
cy.location().toMatchSnapshot(); cy.location().toMatchSnapshot();
getOutfitPreview().toMatchImageSnapshot(); page.getOutfitPreview().toMatchImageSnapshot();
getColorSelect().select("Red"); page.getColorSelect().select("Red");
getSpeciesSelect().find(":selected").should("have.text", "Aisha"); page.getSpeciesSelect().find(":selected").should("have.text", "Aisha");
getColorSelect().find(":selected").should("have.text", "Red"); page.getColorSelect().find(":selected").should("have.text", "Red");
cy.location().toMatchSnapshot(); 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"); cy.visit("/outfits/new?species=1&color=8&pose=HAPPY_FEM");
getPosePickerButton(networkTimeout).click(); page.getPosePickerButton(networkTimeout).click();
getPosePickerOption("Happy and Feminine").should("be.checked"); page.getPosePickerOption("Happy and Feminine").should("be.checked");
cy.location().toMatchSnapshot(); cy.location().toMatchSnapshot();
getOutfitPreview().toMatchImageSnapshot(); page.getOutfitPreview().toMatchImageSnapshot();
getPosePickerOption("Sad and Masculine").check({ force: true }); page.getPosePickerOption("Sad and Masculine").check({ force: true });
getPosePickerOption("Sad and Masculine").should("be.checked"); page.getPosePickerOption("Sad and Masculine").should("be.checked");
cy.location().toMatchSnapshot(); cy.location().toMatchSnapshot();
getOutfitPreview().toMatchImageSnapshot(); page.getOutfitPreview().toMatchImageSnapshot();
}); });
it("Toggles item", () => { it("Toggles item", () => {
cy.visit("/outfits/new?species=1&color=8&objects[]=76789"); cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
getOutfitPreview().toMatchImageSnapshot(); page.getOutfitPreview().toMatchImageSnapshot();
cy.location().toMatchSnapshot(); cy.location().toMatchSnapshot();
cy.contains("A Warm Winters Night Background").click(); cy.contains("A Warm Winters Night Background").click();
getOutfitPreview().toMatchImageSnapshot(); page.getOutfitPreview().toMatchImageSnapshot();
cy.location().toMatchSnapshot(); cy.location().toMatchSnapshot();
}); });
it("Renames outfit", () => { it("Renames outfit", () => {
cy.visit("/outfits/new?name=My+outfit&species=1&color=8"); 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(); 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);
}

View file

@ -1,6 +1,24 @@
import * as page from "./page";
describe("WardrobePage: Outfit saving", () => { describe("WardrobePage: Outfit saving", () => {
it("logs in", () => { it("logs in", () => {
cy.logInAs("dti-test"); 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);
}); });
}); });

View file

@ -125,3 +125,19 @@ exports[`WardrobePage: Basic outfit state > Changes pose #1`] =
"search": "?name=&species=1&color=8&pose=SAD_MASC", "search": "?name=&species=1&color=8&pose=SAD_MASC",
"superDomain": "localhost" "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"
};

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

View file

@ -16,9 +16,11 @@ import {
MenuItem, MenuItem,
Portal, Portal,
Button, Button,
useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { EditIcon, QuestionIcon } from "@chakra-ui/icons"; import { EditIcon, QuestionIcon } from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group"; import { CSSTransition, TransitionGroup } from "react-transition-group";
import { useHistory } from "react-router-dom";
import { Delay, Heading1, Heading2 } from "../util"; import { Delay, Heading1, Heading2 } from "../util";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
@ -26,6 +28,8 @@ import { BiRename } from "react-icons/bi";
import { IoCloudUploadOutline } from "react-icons/io5"; import { IoCloudUploadOutline } from "react-icons/io5";
import { MdMoreVert } from "react-icons/md"; import { MdMoreVert } from "react-icons/md";
import useCurrentUser from "../components/useCurrentUser"; 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 * 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! * OutfitHeading is an editable outfit name, as a big pretty page heading!
* It also contains the outfit menu, for saving etc. * It also contains the outfit menu, for saving etc.
*/ */
function OutfitHeading({ outfitState, dispatchToOutfit }) { function OutfitHeading({ outfitState, dispatchToOutfit }) {
const { isLoggedIn } = useCurrentUser(); const { canSaveOutfit, isSaving, saveOutfit } = useOutfitSaving(outfitState);
return ( return (
// The Editable wraps everything, including the menu, because the menu has // The Editable wraps everything, including the menu, because the menu has
@ -277,25 +380,26 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
</Box> </Box>
</Box> </Box>
<Box width="4" flex="1 0 auto" /> <Box width="4" flex="1 0 auto" />
{isLoggedIn && ( {canSaveOutfit && (
<> <>
<Tooltip label="Coming soon!" shouldWrapChildren> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" isLoading={isSaving}
isDisabled loadingText="Saving…"
leftIcon={ leftIcon={
<Box <Box
// Adjust the visual balance toward the cloud // Adjust the visual balance toward the cloud
marginBottom="-2px" marginBottom="-2px"
> >
<IoCloudUploadOutline /> <IoCloudUploadOutline />
</Box> </Box>
} }
> onClick={saveOutfit}
Save data-test-id="wardrobe-save-outfit-button"
</Button> >
</Tooltip> Save
</Button>
<Box width="2" /> <Box width="2" />
</> </>
)} )}

View file

@ -1,4 +1,5 @@
import { gql } from "apollo-server"; import { gql } from "apollo-server";
import { getPoseFromPetState } from "../util";
const typeDefs = gql` const typeDefs = gql`
type Outfit { type Outfit {
@ -17,6 +18,18 @@ const typeDefs = gql`
extend type Query { extend type Query {
outfit(id: ID!): Outfit 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 = { const resolvers = {
@ -77,6 +90,7 @@ const resolvers = {
return { id: outfit.userId }; return { id: outfit.userId };
}, },
}, },
Query: { Query: {
outfit: async (_, { id }, { outfitLoader }) => { outfit: async (_, { id }, { outfitLoader }) => {
const outfit = await outfitLoader.load(id); const outfit = await outfitLoader.load(id);
@ -87,6 +101,98 @@ const resolvers = {
return { id }; 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 }; module.exports = { typeDefs, resolvers };