1
0
Fork 0

[WIP] Outfit-saving UI

The auto-saving frontend! It seems to trigger saves at the right times, but they fail, because the backend doesn't support updates yet!
This commit is contained in:
Emi Matchu 2021-04-28 15:04:18 -07:00
parent 18a3ddfbad
commit 217aa8dcc1
2 changed files with 181 additions and 88 deletions
src/app/WardrobePage

View file

@ -17,23 +17,19 @@ import {
Portal, Portal,
Button, Button,
useToast, useToast,
Popover, Spinner,
PopoverTrigger, useColorModeValue,
PopoverContent,
PopoverArrow,
PopoverBody,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
CheckIcon, CheckIcon,
EditIcon, EditIcon,
ExternalLinkIcon,
QuestionIcon, QuestionIcon,
WarningIcon, WarningTwoIcon,
} from "@chakra-ui/icons"; } 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 { useHistory } from "react-router-dom";
import { Delay, Heading1, Heading2 } from "../util"; import { Delay, Heading1, Heading2, useDebounce } from "../util";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import { BiRename } from "react-icons/bi"; import { BiRename } from "react-icons/bi";
import { IoCloudUploadOutline } from "react-icons/io5"; import { IoCloudUploadOutline } from "react-icons/io5";
@ -267,6 +263,10 @@ function useOutfitSaving(outfitState) {
const history = useHistory(); const history = useHistory();
const toast = useToast(); const toast = useToast();
// There's not a way to reset an Apollo mutation state to clear out the error
// when the outfit changes… so we track the error state ourselves!
const [saveError, setSaveError] = React.useState(null);
// Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved // Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
// to the server. // to the server.
const isNewOutfit = outfitState.id == null; const isNewOutfit = outfitState.id == null;
@ -326,15 +326,6 @@ function useOutfitSaving(outfitState) {
} }
`, `,
{ {
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 }, context: { sendAuth: true },
update: (cache, { data: { outfit } }) => { update: (cache, { data: { outfit } }) => {
// After save, add this outfit to the current user's outfit list. This // After save, add this outfit to the current user's outfit list. This
@ -360,29 +351,99 @@ function useOutfitSaving(outfitState) {
} }
); );
const saveOutfit = React.useCallback(() => { const saveOutfitFromProvidedState = React.useCallback(
sendSaveOutfitMutation() (outfitState) => {
.then(({ data: { outfit } }) => { sendSaveOutfitMutation({
// Navigate to the new saved outfit URL. Our Apollo cache should pick variables: {
// up the data from this mutation response, and combine it with the id: outfitState.id, // Optional, is null when saving new outfits
// existing cached data, to make this smooth without any loading UI. name: outfitState.name, // Optional, server may fill in a placeholder
history.push(`/outfits/${outfit.id}`); speciesId: outfitState.speciesId,
colorId: outfitState.colorId,
pose: outfitState.pose,
wornItemIds: outfitState.wornItemIds,
closetedItemIds: outfitState.closetedItemIds,
},
}) })
.catch((e) => { .then(({ data: { outfit } }) => {
console.error(e); // Navigate to the new saved outfit URL. Our Apollo cache should pick
toast({ // up the data from this mutation response, and combine it with the
status: "error", // existing cached data, to make this smooth without any loading UI.
title: "Sorry, there was an error saving this outfit!", history.push(`/outfits/${outfit.id}`);
description: "Maybe check your connection and try again.", })
.catch((e) => {
console.error(e);
setSaveError(e);
toast({
status: "error",
title: "Sorry, there was an error saving this outfit!",
description: "Maybe check your connection and try again.",
});
}); });
}); },
}, [sendSaveOutfitMutation, history, toast]); // It's important that this callback _doesn't_ change when the outfit
// changes, so that the auto-save effect is only responding to the
// debounced state!
[sendSaveOutfitMutation, history, toast]
);
const saveOutfit = React.useCallback(
() => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
[saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras]
);
// Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
// which only contains the basic fields, and will keep a stable object
// identity until actual changes occur. Then, save the outfit after the user
// has left it alone for long enough, so long as it's actually different
// than the saved state.
const debouncedOutfitState = useDebounce(
outfitState.outfitStateWithoutExtras,
2000
);
// HACK: This prevents us from auto-saving the outfit state that's still
// loading. I worry that this might not catch other loading scenarios
// though, like if the species/color/pose is in the GQL cache, but the
// items are still loading in... not sure where this would happen tho!
const debouncedOutfitStateIsSaveable =
debouncedOutfitState.speciesId &&
debouncedOutfitState.colorId &&
debouncedOutfitState.pose;
React.useEffect(() => {
if (
!isNewOutfit &&
canSaveOutfit &&
debouncedOutfitStateIsSaveable &&
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
) {
console.info(
"[useOutfitSaving] Auto-saving outfit from old state to new state",
outfitState.savedOutfitState,
debouncedOutfitState
);
saveOutfitFromProvidedState(debouncedOutfitState);
}
}, [
isNewOutfit,
canSaveOutfit,
debouncedOutfitState,
debouncedOutfitStateIsSaveable,
outfitState.savedOutfitState,
saveOutfitFromProvidedState,
]);
// When the outfit changes, clear out the error state from previous saves.
// We'll send the mutation again after the debounce, and we don't want to
// show the error UI in the meantime!
React.useEffect(() => {
setSaveError(null);
}, [outfitState.outfitStateWithoutExtras]);
return { return {
canSaveOutfit, canSaveOutfit,
isNewOutfit, isNewOutfit,
isSaving, isSaving,
latestVersionIsSaved, latestVersionIsSaved,
saveError,
saveOutfit, saveOutfit,
}; };
} }
@ -397,9 +458,12 @@ function OutfitSavingIndicator({ outfitState }) {
isNewOutfit, isNewOutfit,
isSaving, isSaving,
latestVersionIsSaved, latestVersionIsSaved,
saveError,
saveOutfit, saveOutfit,
} = useOutfitSaving(outfitState); } = useOutfitSaving(outfitState);
const errorTextColor = useColorModeValue("red.600", "red.400");
if (!canSaveOutfit) { if (!canSaveOutfit) {
return null; return null;
} }
@ -427,6 +491,24 @@ function OutfitSavingIndicator({ outfitState }) {
); );
} }
if (isSaving) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator"
>
<Spinner
size="xs"
marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saving
</Flex>
);
}
if (latestVersionIsSaved) { if (latestVersionIsSaved) {
return ( return (
<Flex <Flex
@ -444,37 +526,27 @@ function OutfitSavingIndicator({ outfitState }) {
); );
} }
return ( if (saveError) {
<Popover trigger="hover"> return (
<PopoverTrigger> <Flex
<Flex align="center" fontSize="xs" tabIndex="0"> align="center"
<WarningIcon fontSize="xs"
marginRight="1" data-test-id="wardrobe-outfit-save-error-indicator"
// HACK: Not sure why my various centering things always feel wrong... color={errorTextColor}
marginBottom="-2px" >
/> <WarningTwoIcon
Not saved marginRight="1"
</Flex> // HACK: Not sure why my various centering things always feel wrong...
</PopoverTrigger> marginBottom="-2px"
<PopoverContent> />
<PopoverArrow /> Error saving
<PopoverBody> </Flex>
We're still working on this! For now, use{" "} );
<Box }
as="a"
href={`https://impress.openneo.net/outfits/${outfitState.id}`} // The most common way we'll hit this null is when the outfit is changing,
target="_blank" // but the debouncing isn't done yet, so it's not saving yet.
> return null;
<Box as="span" textDecoration="underline">
Classic DTI
</Box>
<ExternalLinkIcon marginLeft="1" marginTop="-3px" fontSize="sm" />
</Box>{" "}
to save existing outfits.
</PopoverBody>
</PopoverContent>
</Popover>
);
} }
/** /**

View file

@ -2,7 +2,7 @@ import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import produce, { enableMapSet } from "immer"; import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client"; import { useQuery, useApolloClient } from "@apollo/client";
import { useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { itemAppearanceFragment } from "../components/useOutfitAppearance"; import { itemAppearanceFragment } from "../components/useOutfitAppearance";
@ -70,7 +70,12 @@ function useOutfitState() {
const creator = outfitData?.outfit?.creator; const creator = outfitData?.outfit?.creator;
const savedOutfitState = getOutfitStateFromOutfitData(outfitData?.outfit); // We memoize this to make `outfitStateWithoutExtras` an even more reliable
// stable object!
const savedOutfitState = React.useMemo(
() => getOutfitStateFromOutfitData(outfitData?.outfit),
[outfitData?.outfit]
);
// Choose which customization state to use. We want it to match the outfit in // 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 // the URL immediately, without having to wait for any effects, to avoid race
@ -92,14 +97,17 @@ function useOutfitState() {
if (urlOutfitState.id === localOutfitState.id) { if (urlOutfitState.id === localOutfitState.id) {
// Use the reducer state: they're both for the same saved outfit, or both // Use the reducer state: they're both for the same saved outfit, or both
// for an unsaved outfit (null === null). // for an unsaved outfit (null === null).
console.debug("Choosing local outfit state");
outfitState = localOutfitState; outfitState = localOutfitState;
} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) { } else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
// Use the saved outfit state: it's for the saved outfit the URL points to. // Use the saved outfit state: it's for the saved outfit the URL points to.
console.debug("Choosing saved outfit state");
outfitState = savedOutfitState; outfitState = savedOutfitState;
} else { } else {
// Use the URL state: it's more up-to-date than any of the others. (Worst // 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 // case, it's empty except for ID, which is fine while the saved outfit
// data loads!) // data loads!)
console.debug("Choosing URL outfit state");
outfitState = urlOutfitState; outfitState = urlOutfitState;
} }
@ -231,6 +239,12 @@ function useOutfitState() {
pose, pose,
appearanceId, appearanceId,
url, url,
// We use this plain outfit state objects in `useOutfitSaving`! Unlike the
// full `outfitState` object, which we rebuild each render,
// `outfitStateWithoutExtras` will mostly only change when there is an
// actual change to outfit state.
outfitStateWithoutExtras: outfitState,
savedOutfitState, savedOutfitState,
}; };
@ -248,7 +262,7 @@ function useOutfitState() {
} }
const outfitStateReducer = (apolloClient) => (baseState, action) => { const outfitStateReducer = (apolloClient) => (baseState, action) => {
console.info("[Outfit state] Action:", action); console.info("[useOutfitState] Action:", action);
switch (action.type) { switch (action.type) {
case "rename": case "rename":
return produce(baseState, (state) => { return produce(baseState, (state) => {
@ -356,29 +370,36 @@ const EMPTY_CUSTOMIZATION_STATE = {
function useParseOutfitUrl() { function useParseOutfitUrl() {
const { id } = useParams(); const { id } = useParams();
const { search } = useLocation();
// For the /outfits/:id page, ignore the query string, and just wait for the // We memoize this to make `outfitStateWithoutExtras` an even more reliable
// outfit data to load in! // stable object!
if (id != null) { const memoizedOutfitState = React.useMemo(() => {
// For the /outfits/:id page, ignore the query string, and just wait for the
// outfit data to load in!
if (id != null) {
return {
...EMPTY_CUSTOMIZATION_STATE,
id,
};
}
// Otherwise, parse the query string, and fill in default values for anything
// not specified.
const urlParams = new URLSearchParams(search);
return { return {
...EMPTY_CUSTOMIZATION_STATE, id: null,
id, name: urlParams.get("name"),
speciesId: urlParams.get("species") || "1",
colorId: urlParams.get("color") || "8",
pose: urlParams.get("pose") || "HAPPY_FEM",
appearanceId: urlParams.get("state") || null,
wornItemIds: new Set(urlParams.getAll("objects[]")),
closetedItemIds: new Set(urlParams.getAll("closet[]")),
}; };
} }, [id, search]);
// Otherwise, parse the query string, and fill in default values for anything return memoizedOutfitState;
// not specified.
const urlParams = new URLSearchParams(window.location.search);
return {
id: null,
name: urlParams.get("name"),
speciesId: urlParams.get("species") || "1",
colorId: urlParams.get("color") || "8",
pose: urlParams.get("pose") || "HAPPY_FEM",
appearanceId: urlParams.get("state") || null,
wornItemIds: new Set(urlParams.getAll("objects[]")),
closetedItemIds: new Set(urlParams.getAll("closet[]")),
};
} }
function getOutfitStateFromOutfitData(outfit) { function getOutfitStateFromOutfitData(outfit) {
@ -603,9 +624,9 @@ function buildOutfitQueryString(outfitState) {
const params = new URLSearchParams({ const params = new URLSearchParams({
name: name || "", name: name || "",
species: speciesId, species: speciesId || "",
color: colorId, color: colorId || "",
pose, pose: pose || "",
}); });
for (const itemId of wornItemIds) { for (const itemId of wornItemIds) {
params.append("objects[]", itemId); params.append("objects[]", itemId);