Merge branch 'outfit-saving' into main
This commit is contained in:
commit
fd931c064e
23 changed files with 1010 additions and 144 deletions
cypress.jsonpackage.jsonyarn.lock
cypress
integration/WardrobePage
Basic outfit state.spec.jsSearchPanel.spec.js
__image_snapshots__
WardrobePage Basic outfit state Changes pose #0.pngWardrobePage Basic outfit state Changes pose #1.pngWardrobePage Basic outfit state Changes species and color #0.pngWardrobePage Basic outfit state Changes species and color #1.pngWardrobePage Basic outfit state Changes species and color #2.pngWardrobePage Basic outfit state Initialize simple outfit from URL #0.pngWardrobePage Basic outfit state Toggles item #0.pngWardrobePage Basic outfit state Toggles item #1.png
__snapshots__
plugins
support
src/app
WardrobePage
components
|
@ -1,3 +1,4 @@
|
||||||
{
|
{
|
||||||
"baseUrl": "http://localhost:3000"
|
"baseUrl": "http://localhost:3000",
|
||||||
|
"ignoreTestFiles": ["**/__snapshots__/*", "**/__image_snapshots__/*"]
|
||||||
}
|
}
|
||||||
|
|
109
cypress/integration/WardrobePage/Basic outfit state.spec.js
Normal file
109
cypress/integration/WardrobePage/Basic outfit state.spec.js
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
// Give network requests a bit of breathing room!
|
||||||
|
const networkTimeout = { timeout: 12000 };
|
||||||
|
|
||||||
|
describe("WardrobePage: Basic outfit state", () => {
|
||||||
|
it("Initialize simple outfit from URL", () => {
|
||||||
|
cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
|
||||||
|
|
||||||
|
getSpeciesSelect(networkTimeout)
|
||||||
|
.find(":selected")
|
||||||
|
.should("have.text", "Acara");
|
||||||
|
getColorSelect().find(":selected").should("have.text", "Blue");
|
||||||
|
cy.location().toMatchSnapshot();
|
||||||
|
|
||||||
|
cy.contains("A Warm Winters Night Background", networkTimeout).should(
|
||||||
|
"exist"
|
||||||
|
);
|
||||||
|
|
||||||
|
getOutfitPreview().toMatchImageSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Changes species and color", () => {
|
||||||
|
cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
|
||||||
|
|
||||||
|
getSpeciesSelect(networkTimeout)
|
||||||
|
.find(":selected")
|
||||||
|
.should("have.text", "Acara");
|
||||||
|
getColorSelect().find(":selected").should("have.text", "Blue");
|
||||||
|
cy.location().toMatchSnapshot();
|
||||||
|
getOutfitPreview().toMatchImageSnapshot();
|
||||||
|
|
||||||
|
getSpeciesSelect().select("Aisha");
|
||||||
|
|
||||||
|
getSpeciesSelect().find(":selected").should("have.text", "Aisha");
|
||||||
|
getColorSelect().find(":selected").should("have.text", "Blue");
|
||||||
|
cy.location().toMatchSnapshot();
|
||||||
|
getOutfitPreview().toMatchImageSnapshot();
|
||||||
|
|
||||||
|
getColorSelect().select("Red");
|
||||||
|
|
||||||
|
getSpeciesSelect().find(":selected").should("have.text", "Aisha");
|
||||||
|
getColorSelect().find(":selected").should("have.text", "Red");
|
||||||
|
cy.location().toMatchSnapshot();
|
||||||
|
getOutfitPreview().toMatchImageSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.only("Changes pose", () => {
|
||||||
|
cy.visit("/outfits/new?species=1&color=8&pose=HAPPY_FEM");
|
||||||
|
|
||||||
|
getPosePickerButton(networkTimeout).click();
|
||||||
|
getPosePickerOption("Happy and Feminine").should("be.checked");
|
||||||
|
cy.location().toMatchSnapshot();
|
||||||
|
getOutfitPreview().toMatchImageSnapshot();
|
||||||
|
|
||||||
|
getPosePickerOption("Sad and Masculine").check({ force: true });
|
||||||
|
getPosePickerOption("Sad and Masculine").should("be.checked");
|
||||||
|
cy.location().toMatchSnapshot();
|
||||||
|
getOutfitPreview().toMatchImageSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Toggles item", () => {
|
||||||
|
cy.visit("/outfits/new?species=1&color=8&objects[]=76789");
|
||||||
|
|
||||||
|
getOutfitPreview().toMatchImageSnapshot();
|
||||||
|
cy.location().toMatchSnapshot();
|
||||||
|
|
||||||
|
cy.contains("A Warm Winters Night Background").click();
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
getOutfitName().click().type("Awesome outfit{enter}");
|
||||||
|
|
||||||
|
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,10 +1,10 @@
|
||||||
// Give network requests a bit of breathing room!
|
// Give network requests a bit of breathing room!
|
||||||
const networkTimeout = { timeout: 6000 };
|
const networkTimeout = { timeout: 10000 };
|
||||||
|
|
||||||
describe("WardrobePage: SearchPanel", () => {
|
describe("WardrobePage: SearchPanel", () => {
|
||||||
// NOTE: This test depends on specific search results on certain pages, and
|
// NOTE: This test depends on specific search results on certain pages, and
|
||||||
// could break if a lot of matching items are added to the site!
|
// could break if a lot of matching items are added to the site!
|
||||||
it.only("Searches by keyword", () => {
|
it("Searches by keyword", () => {
|
||||||
cy.visit("/outfits/new");
|
cy.visit("/outfits/new");
|
||||||
|
|
||||||
// The first page should contain this item.
|
// The first page should contain this item.
|
||||||
|
|
Binary file not shown.
After ![]() (image error) Size: 62 KiB |
Binary file not shown.
After ![]() (image error) Size: 62 KiB |
Binary file not shown.
After ![]() (image error) Size: 288 KiB |
Binary file not shown.
After ![]() (image error) Size: 284 KiB |
Binary file not shown.
After ![]() (image error) Size: 280 KiB |
Binary file not shown.
After ![]() (image error) Size: 288 KiB |
Binary file not shown.
After ![]() (image error) Size: 288 KiB |
Binary file not shown.
After ![]() (image error) Size: 51 KiB |
|
@ -0,0 +1,127 @@
|
||||||
|
exports[`WardrobePage: Basic outfit state > Initialize simple outfit from URL #0`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=1&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=1&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
||||||
|
|
||||||
|
exports[`WardrobePage: Basic outfit state > Changes species and color #0`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=1&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=1&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
||||||
|
|
||||||
|
exports[`WardrobePage: Basic outfit state > Changes species and color #1`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=2&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=2&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
||||||
|
|
||||||
|
exports[`WardrobePage: Basic outfit state > Changes species and color #2`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=2&color=61&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=2&color=61&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
||||||
|
|
||||||
|
exports[`WardrobePage: Basic outfit state > Toggles item #0`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=1&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=1&color=8&pose=HAPPY_FEM&objects%5B%5D=76789",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
||||||
|
|
||||||
|
exports[`WardrobePage: Basic outfit state > Toggles item #1`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=1&color=8&pose=HAPPY_FEM&closet%5B%5D=76789",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=1&color=8&pose=HAPPY_FEM&closet%5B%5D=76789",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
||||||
|
|
||||||
|
exports[`WardrobePage: Basic outfit state > Changes pose #0`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=1&color=8&pose=HAPPY_FEM",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=1&color=8&pose=HAPPY_FEM",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
||||||
|
|
||||||
|
exports[`WardrobePage: Basic outfit state > Changes pose #1`] =
|
||||||
|
{
|
||||||
|
"auth": "",
|
||||||
|
"hash": "",
|
||||||
|
"host": "localhost:3000",
|
||||||
|
"hostname": "localhost",
|
||||||
|
"href": "http://localhost:3000/outfits/new?name=&species=1&color=8&pose=SAD_MASC",
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
"originPolicy": "http://localhost:3000",
|
||||||
|
"pathname": "/outfits/new",
|
||||||
|
"port": "3000",
|
||||||
|
"protocol": "http:",
|
||||||
|
"search": "?name=&species=1&color=8&pose=SAD_MASC",
|
||||||
|
"superDomain": "localhost"
|
||||||
|
};
|
|
@ -1,21 +1,6 @@
|
||||||
/// <reference types="cypress" />
|
const { initPlugin } = require("cypress-plugin-snapshots/plugin");
|
||||||
// ***********************************************************
|
|
||||||
// This example plugins/index.js can be used to load plugins
|
|
||||||
//
|
|
||||||
// You can change the location of this file or turn off loading
|
|
||||||
// the plugins file with the 'pluginsFile' configuration option.
|
|
||||||
//
|
|
||||||
// You can read more here:
|
|
||||||
// https://on.cypress.io/plugins-guide
|
|
||||||
// ***********************************************************
|
|
||||||
|
|
||||||
// This function is called when a project is opened or re-opened (e.g. due to
|
|
||||||
// the project's config changing)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {Cypress.PluginConfig}
|
|
||||||
*/
|
|
||||||
module.exports = (on, config) => {
|
module.exports = (on, config) => {
|
||||||
// `on` is used to hook into various events Cypress emits
|
initPlugin(on, config);
|
||||||
// `config` is the resolved Cypress config
|
return config;
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,25 +1 @@
|
||||||
// ***********************************************
|
import "cypress-plugin-snapshots/commands";
|
||||||
// This example commands.js shows you how to
|
|
||||||
// create various custom commands and overwrite
|
|
||||||
// existing commands.
|
|
||||||
//
|
|
||||||
// For more comprehensive examples of custom
|
|
||||||
// commands please read more here:
|
|
||||||
// https://on.cypress.io/custom-commands
|
|
||||||
// ***********************************************
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a parent command --
|
|
||||||
// Cypress.Commands.add("login", (email, password) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a child command --
|
|
||||||
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a dual command --
|
|
||||||
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This will overwrite an existing command --
|
|
||||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
|
||||||
|
|
|
@ -89,6 +89,7 @@
|
||||||
"apollo-server-testing": "^2.12.0",
|
"apollo-server-testing": "^2.12.0",
|
||||||
"auth0": "^2.28.0",
|
"auth0": "^2.28.0",
|
||||||
"cypress": "^6.4.0",
|
"cypress": "^6.4.0",
|
||||||
|
"cypress-plugin-snapshots": "^1.4.4",
|
||||||
"dotenv-cli": "^3.1.0",
|
"dotenv-cli": "^3.1.0",
|
||||||
"es6-promise-pool": "^2.5.0",
|
"es6-promise-pool": "^2.5.0",
|
||||||
"inquirer": "^7.3.3",
|
"inquirer": "^7.3.3",
|
||||||
|
|
|
@ -271,7 +271,7 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
|
||||||
<Box>
|
<Box>
|
||||||
<Box role="group" d="inline-block" position="relative" width="100%">
|
<Box role="group" d="inline-block" position="relative" width="100%">
|
||||||
<Heading1>
|
<Heading1>
|
||||||
<EditablePreview lineHeight="48px" />
|
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
|
||||||
<EditableInput lineHeight="48px" />
|
<EditableInput lineHeight="48px" />
|
||||||
</Heading1>
|
</Heading1>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -197,6 +197,12 @@ function OutfitControls({
|
||||||
idealPose={outfitState.pose}
|
idealPose={outfitState.pose}
|
||||||
onChange={onSpeciesColorChange}
|
onChange={onSpeciesColorChange}
|
||||||
stateMustAlwaysBeValid
|
stateMustAlwaysBeValid
|
||||||
|
speciesPickerProps={{
|
||||||
|
"data-test-id": "wardrobe-species-picker",
|
||||||
|
}}
|
||||||
|
colorPickerProps={{
|
||||||
|
"data-test-id": "wardrobe-color-picker",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</DarkMode>
|
</DarkMode>
|
||||||
|
@ -210,6 +216,7 @@ function OutfitControls({
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
onLockFocus={onLockFocus}
|
onLockFocus={onLockFocus}
|
||||||
onUnlockFocus={onUnlockFocus}
|
onUnlockFocus={onUnlockFocus}
|
||||||
|
data-test-id="wardrobe-pose-picker"
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
|
@ -63,6 +63,7 @@ function PosePicker({
|
||||||
dispatchToOutfit,
|
dispatchToOutfit,
|
||||||
onLockFocus,
|
onLockFocus,
|
||||||
onUnlockFocus,
|
onUnlockFocus,
|
||||||
|
...props
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const initialFocusRef = React.useRef();
|
const initialFocusRef = React.useRef();
|
||||||
|
@ -193,6 +194,7 @@ function PosePicker({
|
||||||
`,
|
`,
|
||||||
isOpen && "is-open"
|
isOpen && "is-open"
|
||||||
)}
|
)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<EmojiImage src={getIcon(pose)} alt="Choose a pose" />
|
<EmojiImage src={getIcon(pose)} alt="Choose a pose" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -30,6 +30,7 @@ function WardrobePreviewAndControls({
|
||||||
wornItemIds: outfitState.wornItemIds,
|
wornItemIds: outfitState.wornItemIds,
|
||||||
onChangeHasAnimations: setHasAnimations,
|
onChangeHasAnimations: setHasAnimations,
|
||||||
backdrop: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
|
backdrop: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
|
||||||
|
"data-test-id": "wardrobe-outfit-preview",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -12,10 +12,10 @@ export const OutfitStateContext = React.createContext(null);
|
||||||
|
|
||||||
function useOutfitState() {
|
function useOutfitState() {
|
||||||
const apolloClient = useApolloClient();
|
const apolloClient = useApolloClient();
|
||||||
const initialState = useParseOutfitUrl();
|
const urlOutfitState = useParseOutfitUrl();
|
||||||
const [state, dispatchToOutfit] = React.useReducer(
|
const [localOutfitState, dispatchToOutfit] = React.useReducer(
|
||||||
outfitStateReducer(apolloClient),
|
outfitStateReducer(apolloClient),
|
||||||
initialState
|
urlOutfitState
|
||||||
);
|
);
|
||||||
|
|
||||||
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
|
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
|
||||||
|
@ -56,55 +56,61 @@ function useOutfitState() {
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
variables: { id: state.id },
|
variables: { id: urlOutfitState.id },
|
||||||
skip: state.id == null,
|
skip: urlOutfitState.id == null,
|
||||||
returnPartialData: true,
|
returnPartialData: true,
|
||||||
onCompleted: (outfitData) => {
|
onCompleted: (outfitData) => {
|
||||||
// This is only called once the _entire_ query loads, regardless of
|
|
||||||
// `returnPartialData`. We just use that for some early UI!
|
|
||||||
//
|
|
||||||
// Even though we do a HACK to make these values visible early, we
|
|
||||||
// still want to write them to state, so that reducers can see them and
|
|
||||||
// edit them!
|
|
||||||
const outfit = outfitData.outfit;
|
|
||||||
dispatchToOutfit({
|
dispatchToOutfit({
|
||||||
type: "reset",
|
type: "reset",
|
||||||
name: outfit.name,
|
newState: getOutfitStateFromOutfitData(outfitData.outfit),
|
||||||
speciesId: outfit.petAppearance.species.id,
|
|
||||||
colorId: outfit.petAppearance.color.id,
|
|
||||||
pose: outfit.petAppearance.pose,
|
|
||||||
wornItemIds: outfit.wornItems.map((item) => item.id),
|
|
||||||
closetedItemIds: outfit.closetedItems.map((item) => item.id),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// HACK: We fall back to outfit data here, to help the loading states go
|
const creator = outfitData?.outfit?.creator;
|
||||||
// smoother. (Otherwise, there's a flicker where `outfitLoading` is false,
|
|
||||||
// but the `reset` action hasn't fired yet.) This also enables partial outfit
|
|
||||||
// data to show early, like the name, if we're navigating from Your Outfits.
|
|
||||||
//
|
|
||||||
// We also call `Array.from` on our item IDs. It's more convenient to manage
|
|
||||||
// them as a Set in state, but most callers will find it more convenient to
|
|
||||||
// access them as arrays! e.g. for `.map()`.
|
|
||||||
const outfit = outfitData?.outfit || null;
|
|
||||||
const id = state.id;
|
|
||||||
const creator = outfit?.creator || null;
|
|
||||||
const name = state.name || outfit?.name || null;
|
|
||||||
const speciesId =
|
|
||||||
state.speciesId || outfit?.petAppearance?.species?.id || null;
|
|
||||||
const colorId = state.colorId || outfit?.petAppearance?.color?.id || null;
|
|
||||||
const pose = state.pose || outfit?.petAppearance?.pose || null;
|
|
||||||
const appearanceId = state.appearanceId || null;
|
|
||||||
const wornItemIds = Array.from(
|
|
||||||
state.wornItemIds || outfit?.wornItems?.map((i) => i.id)
|
|
||||||
);
|
|
||||||
const closetedItemIds = Array.from(
|
|
||||||
state.closetedItemIds || outfit?.closetedItems?.map((i) => i.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const allItemIds = [...state.wornItemIds, ...state.closetedItemIds];
|
const savedOutfitState = getOutfitStateFromOutfitData(outfitData?.outfit);
|
||||||
|
|
||||||
|
// Choose which customization state to use. We want it to match the outfit in
|
||||||
|
// the URL immediately, without having to wait for any effects, to avoid race
|
||||||
|
// conditions!
|
||||||
|
//
|
||||||
|
// The reducer is generally the main source of truth for live changes!
|
||||||
|
//
|
||||||
|
// But if:
|
||||||
|
// - it's not initialized yet (e.g. the first frame of navigating to an
|
||||||
|
// outfit from Your Outfits), or
|
||||||
|
// - it's for a different outfit than the URL says (e.g. clicking Back
|
||||||
|
// or Forward to switch between saved outfits),
|
||||||
|
//
|
||||||
|
// Then use saved outfit data or the URL query string instead, because that's
|
||||||
|
// a better representation of the outfit in the URL. (If the saved outfit
|
||||||
|
// data isn't loaded yet, then this will be a customization state with
|
||||||
|
// partial data, and that's okay.)
|
||||||
|
let outfitState;
|
||||||
|
if (urlOutfitState.id === localOutfitState.id) {
|
||||||
|
// Use the reducer state: they're both for the same saved outfit, or both
|
||||||
|
// for an unsaved outfit (null === null).
|
||||||
|
outfitState = localOutfitState;
|
||||||
|
} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
|
||||||
|
// Use the saved outfit state: it's for the saved outfit the URL points to.
|
||||||
|
outfitState = savedOutfitState;
|
||||||
|
} else {
|
||||||
|
// Use the URL state: it's more up-to-date than any of the others. (Worst
|
||||||
|
// case, it's empty except for ID, which is fine while the saved outfit
|
||||||
|
// data loads!)
|
||||||
|
outfitState = urlOutfitState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When unpacking the customization state, we call `Array.from` on our item
|
||||||
|
// IDs. It's more convenient to manage them as a Set in state, but most
|
||||||
|
// callers will find it more convenient to access them as arrays! e.g. for
|
||||||
|
// `.map()`.
|
||||||
|
const { name, speciesId, colorId, pose, appearanceId } = outfitState;
|
||||||
|
const wornItemIds = Array.from(outfitState.wornItemIds);
|
||||||
|
const closetedItemIds = Array.from(outfitState.closetedItemIds);
|
||||||
|
const allItemIds = [...wornItemIds, ...closetedItemIds];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading: itemsLoading,
|
loading: itemsLoading,
|
||||||
|
@ -209,10 +215,10 @@ function useOutfitState() {
|
||||||
.filter((i) => i.appearanceOn.layers.length === 0)
|
.filter((i) => i.appearanceOn.layers.length === 0)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
const url = buildOutfitUrl(state);
|
const url = buildOutfitUrl(outfitState);
|
||||||
|
|
||||||
const outfitState = {
|
const outfitStateWithExtras = {
|
||||||
id,
|
id: urlOutfitState.outfitId,
|
||||||
creator,
|
creator,
|
||||||
zonesAndItems,
|
zonesAndItems,
|
||||||
incompatibleItems,
|
incompatibleItems,
|
||||||
|
@ -235,7 +241,7 @@ function useOutfitState() {
|
||||||
return {
|
return {
|
||||||
loading: outfitLoading || itemsLoading,
|
loading: outfitLoading || itemsLoading,
|
||||||
error: outfitError || itemsError,
|
error: outfitError || itemsError,
|
||||||
outfitState,
|
outfitState: outfitStateWithExtras,
|
||||||
dispatchToOutfit,
|
dispatchToOutfit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -243,15 +249,16 @@ function useOutfitState() {
|
||||||
const outfitStateReducer = (apolloClient) => (baseState, action) => {
|
const outfitStateReducer = (apolloClient) => (baseState, action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "rename":
|
case "rename":
|
||||||
return { ...baseState, name: action.outfitName };
|
return produce(baseState, (state) => {
|
||||||
|
state.name = action.outfitName;
|
||||||
|
});
|
||||||
case "setSpeciesAndColor":
|
case "setSpeciesAndColor":
|
||||||
return {
|
return produce(baseState, (state) => {
|
||||||
...baseState,
|
state.speciesId = action.speciesId;
|
||||||
speciesId: action.speciesId,
|
state.colorId = action.colorId;
|
||||||
colorId: action.colorId,
|
state.pose = action.pose;
|
||||||
pose: action.pose,
|
state.appearanceId = null;
|
||||||
appearanceId: null,
|
});
|
||||||
};
|
|
||||||
case "wearItem":
|
case "wearItem":
|
||||||
return produce(baseState, (state) => {
|
return produce(baseState, (state) => {
|
||||||
const { wornItemIds, closetedItemIds } = state;
|
const { wornItemIds, closetedItemIds } = state;
|
||||||
|
@ -310,41 +317,31 @@ const outfitStateReducer = (apolloClient) => (baseState, action) => {
|
||||||
reconsiderItems(itemIdsToReconsider, state, apolloClient);
|
reconsiderItems(itemIdsToReconsider, state, apolloClient);
|
||||||
});
|
});
|
||||||
case "setPose":
|
case "setPose":
|
||||||
return {
|
return produce(baseState, (state) => {
|
||||||
...baseState,
|
state.pose = action.pose;
|
||||||
pose: action.pose,
|
|
||||||
|
|
||||||
// Usually only the `pose` is specified, but `PosePickerSupport` can
|
// Usually only the `pose` is specified, but `PosePickerSupport` can
|
||||||
// also specify a corresponding `appearanceId`, to get even more
|
// also specify a corresponding `appearanceId`, to get even more
|
||||||
// particular about which version of the pose to show if more than one.
|
// particular about which version of the pose to show if more than one.
|
||||||
appearanceId: action.appearanceId || null,
|
state.appearanceId = action.appearanceId || null;
|
||||||
};
|
|
||||||
case "reset":
|
|
||||||
return produce(baseState, (state) => {
|
|
||||||
const {
|
|
||||||
name,
|
|
||||||
speciesId,
|
|
||||||
colorId,
|
|
||||||
pose,
|
|
||||||
wornItemIds,
|
|
||||||
closetedItemIds,
|
|
||||||
} = action;
|
|
||||||
state.name = name;
|
|
||||||
state.speciesId = speciesId ? String(speciesId) : baseState.speciesId;
|
|
||||||
state.colorId = colorId ? String(colorId) : baseState.colorId;
|
|
||||||
state.pose = pose || baseState.pose;
|
|
||||||
state.wornItemIds = wornItemIds
|
|
||||||
? new Set(wornItemIds.map(String))
|
|
||||||
: baseState.wornItemIds;
|
|
||||||
state.closetedItemIds = closetedItemIds
|
|
||||||
? new Set(closetedItemIds.map(String))
|
|
||||||
: baseState.closetedItemIds;
|
|
||||||
});
|
});
|
||||||
|
case "reset":
|
||||||
|
return action.newState;
|
||||||
default:
|
default:
|
||||||
throw new Error(`unexpected action ${JSON.stringify(action)}`);
|
throw new Error(`unexpected action ${JSON.stringify(action)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_CUSTOMIZATION_STATE = {
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
speciesId: null,
|
||||||
|
colorId: null,
|
||||||
|
pose: null,
|
||||||
|
appearanceId: null,
|
||||||
|
wornItemIds: [],
|
||||||
|
closetedItemIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
function useParseOutfitUrl() {
|
function useParseOutfitUrl() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
|
@ -352,14 +349,8 @@ function useParseOutfitUrl() {
|
||||||
// outfit data to load in!
|
// outfit data to load in!
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
return {
|
return {
|
||||||
|
...EMPTY_CUSTOMIZATION_STATE,
|
||||||
id,
|
id,
|
||||||
name: null,
|
|
||||||
speciesId: null,
|
|
||||||
colorId: null,
|
|
||||||
pose: null,
|
|
||||||
appearanceId: null,
|
|
||||||
wornItemIds: [],
|
|
||||||
closetedItemIds: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -367,7 +358,7 @@ function useParseOutfitUrl() {
|
||||||
// not specified.
|
// not specified.
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: null,
|
||||||
name: urlParams.get("name"),
|
name: urlParams.get("name"),
|
||||||
speciesId: urlParams.get("species") || "1",
|
speciesId: urlParams.get("species") || "1",
|
||||||
colorId: urlParams.get("color") || "8",
|
colorId: urlParams.get("color") || "8",
|
||||||
|
@ -378,6 +369,25 @@ function useParseOutfitUrl() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOutfitStateFromOutfitData(outfit) {
|
||||||
|
if (!outfit) {
|
||||||
|
return EMPTY_CUSTOMIZATION_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: outfit.id,
|
||||||
|
name: outfit.name,
|
||||||
|
// Note that these fields are intentionally null if loading, rather than
|
||||||
|
// falling back to a default appearance like Blue Acara.
|
||||||
|
speciesId: outfit.petAppearance?.species?.id,
|
||||||
|
colorId: outfit.petAppearance?.color?.id,
|
||||||
|
pose: outfit.petAppearance?.pose,
|
||||||
|
// Whereas the items are more convenient to just leave as empty lists!
|
||||||
|
wornItemIds: (outfit.wornItems || []).map((item) => item.id),
|
||||||
|
closetedItemIds: (outfit.closetedItems || []).map((item) => item.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function findItemConflicts(itemIdToAdd, state, apolloClient) {
|
function findItemConflicts(itemIdToAdd, state, apolloClient) {
|
||||||
const { wornItemIds, speciesId, colorId } = state;
|
const { wornItemIds, speciesId, colorId } = state;
|
||||||
|
|
||||||
|
@ -554,7 +564,7 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
|
||||||
return zonesAndItems;
|
return zonesAndItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOutfitUrl(state) {
|
function buildOutfitUrl(outfitState) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
@ -564,7 +574,7 @@ function buildOutfitUrl(state) {
|
||||||
appearanceId,
|
appearanceId,
|
||||||
wornItemIds,
|
wornItemIds,
|
||||||
closetedItemIds,
|
closetedItemIds,
|
||||||
} = state;
|
} = outfitState;
|
||||||
|
|
||||||
const { origin, pathname } = window.location;
|
const { origin, pathname } = window.location;
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,7 @@ export function useOutfitPreview({
|
||||||
loadingDelayMs,
|
loadingDelayMs,
|
||||||
spinnerVariant,
|
spinnerVariant,
|
||||||
onChangeHasAnimations = null,
|
onChangeHasAnimations = null,
|
||||||
|
...props
|
||||||
}) {
|
}) {
|
||||||
const appearance = useOutfitAppearance({
|
const appearance = useOutfitAppearance({
|
||||||
speciesId,
|
speciesId,
|
||||||
|
@ -102,6 +103,7 @@ export function useOutfitPreview({
|
||||||
onChangeHasAnimations={onChangeHasAnimations}
|
onChangeHasAnimations={onChangeHasAnimations}
|
||||||
doTransitions
|
doTransitions
|
||||||
isPaused={isPaused}
|
isPaused={isPaused}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -122,6 +124,7 @@ export function OutfitLayers({
|
||||||
spinnerVariant = "overlay",
|
spinnerVariant = "overlay",
|
||||||
doTransitions = false,
|
doTransitions = false,
|
||||||
isPaused = true,
|
isPaused = true,
|
||||||
|
...props
|
||||||
}) {
|
}) {
|
||||||
const containerRef = React.useRef(null);
|
const containerRef = React.useRef(null);
|
||||||
const [canvasSize, setCanvasSize] = React.useState(0);
|
const [canvasSize, setCanvasSize] = React.useState(0);
|
||||||
|
@ -178,6 +181,8 @@ export function OutfitLayers({
|
||||||
// Create a stacking context, so the z-indexed layers don't escape!
|
// Create a stacking context, so the z-indexed layers don't escape!
|
||||||
zIndex="0"
|
zIndex="0"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
data-loading={loading ? true : undefined}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{backdrop && (
|
{backdrop && (
|
||||||
<FullScreenCenter>
|
<FullScreenCenter>
|
||||||
|
|
|
@ -27,6 +27,8 @@ function SpeciesColorPicker({
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
speciesIsDisabled = false,
|
speciesIsDisabled = false,
|
||||||
size = "md",
|
size = "md",
|
||||||
|
speciesPickerProps = {},
|
||||||
|
colorPickerProps = {},
|
||||||
onChange,
|
onChange,
|
||||||
}) {
|
}) {
|
||||||
const { loading: loadingMeta, error: errorMeta, data: meta } = useQuery(gql`
|
const { loading: loadingMeta, error: errorMeta, data: meta } = useQuery(gql`
|
||||||
|
@ -188,6 +190,7 @@ function SpeciesColorPicker({
|
||||||
valids={valids}
|
valids={valids}
|
||||||
speciesId={speciesId}
|
speciesId={speciesId}
|
||||||
colorId={colorId}
|
colorId={colorId}
|
||||||
|
{...colorPickerProps}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
// If the selected color isn't in the set we have here, show the
|
// If the selected color isn't in the set we have here, show the
|
||||||
|
@ -231,6 +234,7 @@ function SpeciesColorPicker({
|
||||||
valids={valids}
|
valids={valids}
|
||||||
speciesId={speciesId}
|
speciesId={speciesId}
|
||||||
colorId={colorId}
|
colorId={colorId}
|
||||||
|
{...speciesPickerProps}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
// If the selected species isn't in the set we have here, show the
|
// If the selected species isn't in the set we have here, show the
|
||||||
|
|
Loading…
Reference in a new issue