Enable auto-saving while searching for items
This means hoisting `useOutfitSaving` up to the top of the page! We had it down lower for iteration convenience to start :)
This commit is contained in:
parent
93bc960221
commit
3088b97ad2
4 changed files with 249 additions and 239 deletions
|
@ -21,7 +21,7 @@ import SearchPanel from "./SearchPanel";
|
||||||
* performing some wiring to help them interact with each other via simple
|
* performing some wiring to help them interact with each other via simple
|
||||||
* state and refs.
|
* state and refs.
|
||||||
*/
|
*/
|
||||||
function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
|
function ItemsAndSearchPanels({ loading, outfitState, outfitSaving, dispatchToOutfit }) {
|
||||||
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
|
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
|
||||||
const scrollContainerRef = React.useRef();
|
const scrollContainerRef = React.useRef();
|
||||||
const searchQueryRef = React.useRef();
|
const searchQueryRef = React.useRef();
|
||||||
|
@ -68,6 +68,7 @@ function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
|
||||||
<ItemsPanel
|
<ItemsPanel
|
||||||
loading={loading}
|
loading={loading}
|
||||||
outfitState={outfitState}
|
outfitState={outfitState}
|
||||||
|
outfitSaving={outfitSaving}
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -16,7 +16,6 @@ import {
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Portal,
|
Portal,
|
||||||
Button,
|
Button,
|
||||||
useToast,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
|
@ -27,17 +26,12 @@ import {
|
||||||
WarningTwoIcon,
|
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 { Delay, Heading1, Heading2, useDebounce } from "../util";
|
import { Delay, Heading1, Heading2 } 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";
|
||||||
import { MdMoreVert } from "react-icons/md";
|
import { MdMoreVert } from "react-icons/md";
|
||||||
import useCurrentUser from "../components/useCurrentUser";
|
|
||||||
import gql from "graphql-tag";
|
|
||||||
import { useMutation } from "@apollo/client";
|
|
||||||
import { outfitStatesAreEqual } from "./useOutfitState";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
@ -52,7 +46,7 @@ import { outfitStatesAreEqual } from "./useOutfitState";
|
||||||
* to have extra padding. Essentially: while the Items _do_ stretch out the
|
* to have extra padding. Essentially: while the Items _do_ stretch out the
|
||||||
* full width of the container, it doesn't look like it!
|
* full width of the container, it doesn't look like it!
|
||||||
*/
|
*/
|
||||||
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
|
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
|
||||||
const { zonesAndItems, incompatibleItems } = outfitState;
|
const { zonesAndItems, incompatibleItems } = outfitState;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -62,6 +56,7 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
|
||||||
<Box px="1">
|
<Box px="1">
|
||||||
<OutfitHeading
|
<OutfitHeading
|
||||||
outfitState={outfitState}
|
outfitState={outfitState}
|
||||||
|
outfitSaving={outfitSaving}
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -258,234 +253,11 @@ function ItemZoneGroupSkeleton({ itemCount }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useOutfitSaving(outfitState, dispatchToOutfit) {
|
|
||||||
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Whether this outfit's latest local changes have been saved to the server.
|
|
||||||
// And log it to the console!
|
|
||||||
const latestVersionIsSaved =
|
|
||||||
outfitState.savedOutfitState &&
|
|
||||||
outfitStatesAreEqual(
|
|
||||||
outfitState.outfitStateWithoutExtras,
|
|
||||||
outfitState.savedOutfitState
|
|
||||||
);
|
|
||||||
React.useEffect(() => {
|
|
||||||
console.debug(
|
|
||||||
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
|
|
||||||
latestVersionIsSaved,
|
|
||||||
outfitState.outfitStateWithoutExtras,
|
|
||||||
outfitState.savedOutfitState
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
latestVersionIsSaved,
|
|
||||||
outfitState.outfitStateWithoutExtras,
|
|
||||||
outfitState.savedOutfitState,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Only logged-in users can save outfits - and they can only save new outfits,
|
|
||||||
// or outfits they created.
|
|
||||||
const canSaveOutfit =
|
|
||||||
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
context: { sendAuth: true },
|
|
||||||
update: (cache, { data: { outfit } }) => {
|
|
||||||
// After save, add this outfit to the current user's outfit list. This
|
|
||||||
// will help when navigating back to Your Outfits, to force a refresh.
|
|
||||||
// https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation
|
|
||||||
cache.modify({
|
|
||||||
id: cache.identify(outfit.creator),
|
|
||||||
fields: {
|
|
||||||
outfits: (existingOutfitRefs = []) => {
|
|
||||||
const newOutfitRef = cache.writeFragment({
|
|
||||||
data: outfit,
|
|
||||||
fragment: gql`
|
|
||||||
fragment NewOutfit on Outfit {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
return [...existingOutfitRefs, newOutfitRef];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also, send a `reset` action, to show whatever the server returned.
|
|
||||||
// This is important for suffix changes to `name`, but can also be
|
|
||||||
// relevant for graceful failure when a bug causes a change not to
|
|
||||||
// persist. (But don't do it if it's not the current outfit anymore,
|
|
||||||
// we don't want laggy mutations to reset the outfit!)
|
|
||||||
if (outfit.id === outfitState.id) {
|
|
||||||
dispatchToOutfit({
|
|
||||||
type: "resetToSavedOutfitData",
|
|
||||||
savedOutfitData: outfit,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.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.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 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,
|
|
||||||
{
|
|
||||||
// When the outfit ID changes, update the debounced state immediately!
|
|
||||||
forceReset: (debouncedOutfitState, newOutfitState) =>
|
|
||||||
debouncedOutfitState.id !== newOutfitState.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// 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\nSaved: %o\nCurrent (debounced): %o",
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state,
|
* OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state,
|
||||||
* if the user can save this outfit. If not, this is empty!
|
* if the user can save this outfit. If not, this is empty!
|
||||||
*/
|
*/
|
||||||
function OutfitSavingIndicator({ outfitState, dispatchToOutfit }) {
|
function OutfitSavingIndicator({ outfitSaving }) {
|
||||||
const {
|
const {
|
||||||
canSaveOutfit,
|
canSaveOutfit,
|
||||||
isNewOutfit,
|
isNewOutfit,
|
||||||
|
@ -493,7 +265,7 @@ function OutfitSavingIndicator({ outfitState, dispatchToOutfit }) {
|
||||||
latestVersionIsSaved,
|
latestVersionIsSaved,
|
||||||
saveError,
|
saveError,
|
||||||
saveOutfit,
|
saveOutfit,
|
||||||
} = useOutfitSaving(outfitState, dispatchToOutfit);
|
} = outfitSaving;
|
||||||
|
|
||||||
const errorTextColor = useColorModeValue("red.600", "red.400");
|
const errorTextColor = useColorModeValue("red.600", "red.400");
|
||||||
|
|
||||||
|
@ -586,7 +358,7 @@ function OutfitSavingIndicator({ outfitState, dispatchToOutfit }) {
|
||||||
* 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, outfitSaving, dispatchToOutfit }) {
|
||||||
return (
|
return (
|
||||||
// The Editable wraps everything, including the menu, because the menu has
|
// The Editable wraps everything, including the menu, because the menu has
|
||||||
// a Rename option.
|
// a Rename option.
|
||||||
|
@ -612,10 +384,7 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
|
||||||
</Box>
|
</Box>
|
||||||
<Box width="4" flex="1 0 auto" />
|
<Box width="4" flex="1 0 auto" />
|
||||||
<Box flex="0 0 auto">
|
<Box flex="0 0 auto">
|
||||||
<OutfitSavingIndicator
|
<OutfitSavingIndicator outfitSaving={outfitSaving} />
|
||||||
outfitState={outfitState}
|
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box width="2" />
|
<Box width="2" />
|
||||||
<Menu placement="bottom-end">
|
<Menu placement="bottom-end">
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { loadable } from "../util";
|
||||||
|
|
||||||
import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
|
import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
|
||||||
import SupportOnly from "./support/SupportOnly";
|
import SupportOnly from "./support/SupportOnly";
|
||||||
|
import useOutfitSaving from "./useOutfitSaving";
|
||||||
import useOutfitState, { OutfitStateContext } from "./useOutfitState";
|
import useOutfitState, { OutfitStateContext } from "./useOutfitState";
|
||||||
import { usePageTitle } from "../util";
|
import { usePageTitle } from "../util";
|
||||||
import WardrobePageLayout from "./WardrobePageLayout";
|
import WardrobePageLayout from "./WardrobePageLayout";
|
||||||
|
@ -26,6 +27,11 @@ function WardrobePage() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
|
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
|
||||||
|
|
||||||
|
// We manage outfit saving up here, rather than at the point of the UI where
|
||||||
|
// "Saving" indicators appear. That way, auto-saving still happens even when
|
||||||
|
// the indicator isn't on the page, e.g. when searching.
|
||||||
|
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
|
||||||
|
|
||||||
usePageTitle(outfitState.name || "Untitled outfit");
|
usePageTitle(outfitState.name || "Untitled outfit");
|
||||||
|
|
||||||
// TODO: I haven't found a great place for this error UI yet, and this case
|
// TODO: I haven't found a great place for this error UI yet, and this case
|
||||||
|
@ -64,6 +70,7 @@ function WardrobePage() {
|
||||||
<ItemsAndSearchPanels
|
<ItemsAndSearchPanels
|
||||||
loading={loading}
|
loading={loading}
|
||||||
outfitState={outfitState}
|
outfitState={outfitState}
|
||||||
|
outfitSaving={outfitSaving}
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
233
src/app/WardrobePage/useOutfitSaving.js
Normal file
233
src/app/WardrobePage/useOutfitSaving.js
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useToast } from "@chakra-ui/react";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
|
import { useDebounce } from "../util";
|
||||||
|
import useCurrentUser from "../components/useCurrentUser";
|
||||||
|
import gql from "graphql-tag";
|
||||||
|
import { useMutation } from "@apollo/client";
|
||||||
|
import { outfitStatesAreEqual } from "./useOutfitState";
|
||||||
|
|
||||||
|
function useOutfitSaving(outfitState, dispatchToOutfit) {
|
||||||
|
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Whether this outfit's latest local changes have been saved to the server.
|
||||||
|
// And log it to the console!
|
||||||
|
const latestVersionIsSaved =
|
||||||
|
outfitState.savedOutfitState &&
|
||||||
|
outfitStatesAreEqual(
|
||||||
|
outfitState.outfitStateWithoutExtras,
|
||||||
|
outfitState.savedOutfitState
|
||||||
|
);
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.debug(
|
||||||
|
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
|
||||||
|
latestVersionIsSaved,
|
||||||
|
outfitState.outfitStateWithoutExtras,
|
||||||
|
outfitState.savedOutfitState
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
latestVersionIsSaved,
|
||||||
|
outfitState.outfitStateWithoutExtras,
|
||||||
|
outfitState.savedOutfitState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only logged-in users can save outfits - and they can only save new outfits,
|
||||||
|
// or outfits they created.
|
||||||
|
const canSaveOutfit =
|
||||||
|
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
context: { sendAuth: true },
|
||||||
|
update: (cache, { data: { outfit } }) => {
|
||||||
|
// After save, add this outfit to the current user's outfit list. This
|
||||||
|
// will help when navigating back to Your Outfits, to force a refresh.
|
||||||
|
// https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation
|
||||||
|
cache.modify({
|
||||||
|
id: cache.identify(outfit.creator),
|
||||||
|
fields: {
|
||||||
|
outfits: (existingOutfitRefs = []) => {
|
||||||
|
const newOutfitRef = cache.writeFragment({
|
||||||
|
data: outfit,
|
||||||
|
fragment: gql`
|
||||||
|
fragment NewOutfit on Outfit {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return [...existingOutfitRefs, newOutfitRef];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also, send a `reset` action, to show whatever the server returned.
|
||||||
|
// This is important for suffix changes to `name`, but can also be
|
||||||
|
// relevant for graceful failure when a bug causes a change not to
|
||||||
|
// persist. (But don't do it if it's not the current outfit anymore,
|
||||||
|
// we don't want laggy mutations to reset the outfit!)
|
||||||
|
if (outfit.id === outfitState.id) {
|
||||||
|
dispatchToOutfit({
|
||||||
|
type: "resetToSavedOutfitData",
|
||||||
|
savedOutfitData: outfit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveOutfitFromProvidedState = React.useCallback(
|
||||||
|
(outfitState) => {
|
||||||
|
sendSaveOutfitMutation({
|
||||||
|
variables: {
|
||||||
|
id: outfitState.id,
|
||||||
|
name: outfitState.name,
|
||||||
|
speciesId: outfitState.speciesId,
|
||||||
|
colorId: outfitState.colorId,
|
||||||
|
pose: outfitState.pose,
|
||||||
|
wornItemIds: [...outfitState.wornItemIds],
|
||||||
|
closetedItemIds: [...outfitState.closetedItemIds],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// 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,
|
||||||
|
{
|
||||||
|
// When the outfit ID changes, update the debounced state immediately!
|
||||||
|
forceReset: (debouncedOutfitState, newOutfitState) =>
|
||||||
|
debouncedOutfitState.id !== newOutfitState.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// 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\nSaved: %o\nCurrent (debounced): %o",
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useOutfitSaving;
|
Loading…
Reference in a new issue