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:
Emi Matchu 2021-05-04 13:28:29 -07:00
parent 93bc960221
commit 3088b97ad2
4 changed files with 249 additions and 239 deletions

View file

@ -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>

View file

@ -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">

View file

@ -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}
/> />
} }

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