diff --git a/src/app/WardrobePage/ItemsPanel.js b/src/app/WardrobePage/ItemsPanel.js
index b8762d3..6a73317 100644
--- a/src/app/WardrobePage/ItemsPanel.js
+++ b/src/app/WardrobePage/ItemsPanel.js
@@ -17,23 +17,19 @@ import {
Portal,
Button,
useToast,
- Popover,
- PopoverTrigger,
- PopoverContent,
- PopoverArrow,
- PopoverBody,
+ Spinner,
+ useColorModeValue,
} from "@chakra-ui/react";
import {
CheckIcon,
EditIcon,
- ExternalLinkIcon,
QuestionIcon,
- WarningIcon,
+ WarningTwoIcon,
} from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group";
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 { BiRename } from "react-icons/bi";
import { IoCloudUploadOutline } from "react-icons/io5";
@@ -267,6 +263,10 @@ function useOutfitSaving(outfitState) {
const history = useHistory();
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
// to the server.
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 },
update: (cache, { data: { outfit } }) => {
// After save, add this outfit to the current user's outfit list. This
@@ -360,29 +351,99 @@ function useOutfitSaving(outfitState) {
}
);
- 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}`);
+ const saveOutfitFromProvidedState = React.useCallback(
+ (outfitState) => {
+ sendSaveOutfitMutation({
+ 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,
+ },
})
- .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.",
+ .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);
+ 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 {
canSaveOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
+ saveError,
saveOutfit,
};
}
@@ -397,9 +458,12 @@ function OutfitSavingIndicator({ outfitState }) {
isNewOutfit,
isSaving,
latestVersionIsSaved,
+ saveError,
saveOutfit,
} = useOutfitSaving(outfitState);
+ const errorTextColor = useColorModeValue("red.600", "red.400");
+
if (!canSaveOutfit) {
return null;
}
@@ -427,6 +491,24 @@ function OutfitSavingIndicator({ outfitState }) {
);
}
+ if (isSaving) {
+ return (
+
+
+ Saving…
+
+ );
+ }
+
if (latestVersionIsSaved) {
return (
-
-
-
- Not saved
-
-
-
-
-
- We're still working on this! For now, use{" "}
-
-
- Classic DTI
-
-
- {" "}
- to save existing outfits.
-
-
-
- );
+ if (saveError) {
+ return (
+
+
+ Error saving
+
+ );
+ }
+
+ // The most common way we'll hit this null is when the outfit is changing,
+ // but the debouncing isn't done yet, so it's not saving yet.
+ return null;
}
/**
diff --git a/src/app/WardrobePage/useOutfitState.js b/src/app/WardrobePage/useOutfitState.js
index 4402f56..8413e50 100644
--- a/src/app/WardrobePage/useOutfitState.js
+++ b/src/app/WardrobePage/useOutfitState.js
@@ -2,7 +2,7 @@ import React from "react";
import gql from "graphql-tag";
import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client";
-import { useParams } from "react-router-dom";
+import { useLocation, useParams } from "react-router-dom";
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
@@ -70,7 +70,12 @@ function useOutfitState() {
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
// the URL immediately, without having to wait for any effects, to avoid race
@@ -92,14 +97,17 @@ function useOutfitState() {
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).
+ console.debug("Choosing local outfit state");
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.
+ console.debug("Choosing saved outfit state");
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!)
+ console.debug("Choosing URL outfit state");
outfitState = urlOutfitState;
}
@@ -231,6 +239,12 @@ function useOutfitState() {
pose,
appearanceId,
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,
};
@@ -248,7 +262,7 @@ function useOutfitState() {
}
const outfitStateReducer = (apolloClient) => (baseState, action) => {
- console.info("[Outfit state] Action:", action);
+ console.info("[useOutfitState] Action:", action);
switch (action.type) {
case "rename":
return produce(baseState, (state) => {
@@ -356,29 +370,36 @@ const EMPTY_CUSTOMIZATION_STATE = {
function useParseOutfitUrl() {
const { id } = useParams();
+ const { search } = useLocation();
- // For the /outfits/:id page, ignore the query string, and just wait for the
- // outfit data to load in!
- if (id != null) {
+ // We memoize this to make `outfitStateWithoutExtras` an even more reliable
+ // stable object!
+ 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 {
- ...EMPTY_CUSTOMIZATION_STATE,
- id,
+ 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[]")),
};
- }
+ }, [id, search]);
- // Otherwise, parse the query string, and fill in default values for anything
- // 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[]")),
- };
+ return memoizedOutfitState;
}
function getOutfitStateFromOutfitData(outfit) {
@@ -603,9 +624,9 @@ function buildOutfitQueryString(outfitState) {
const params = new URLSearchParams({
name: name || "",
- species: speciesId,
- color: colorId,
- pose,
+ species: speciesId || "",
+ color: colorId || "",
+ pose: pose || "",
});
for (const itemId of wornItemIds) {
params.append("objects[]", itemId);