diff --git a/src/app/WardrobePage/ItemsAndSearchPanels.js b/src/app/WardrobePage/ItemsAndSearchPanels.js index b60e17b..0b3657c 100644 --- a/src/app/WardrobePage/ItemsAndSearchPanels.js +++ b/src/app/WardrobePage/ItemsAndSearchPanels.js @@ -21,7 +21,7 @@ import SearchPanel from "./SearchPanel"; * performing some wiring to help them interact with each other via simple * state and refs. */ -function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) { +function ItemsAndSearchPanels({ loading, outfitState, outfitSaving, dispatchToOutfit }) { const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery); const scrollContainerRef = React.useRef(); const searchQueryRef = React.useRef(); @@ -68,6 +68,7 @@ function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) { diff --git a/src/app/WardrobePage/ItemsPanel.js b/src/app/WardrobePage/ItemsPanel.js index d9fbf00..f8402d6 100644 --- a/src/app/WardrobePage/ItemsPanel.js +++ b/src/app/WardrobePage/ItemsPanel.js @@ -16,7 +16,6 @@ import { MenuItem, Portal, Button, - useToast, Spinner, useColorModeValue, } from "@chakra-ui/react"; @@ -27,17 +26,12 @@ import { WarningTwoIcon, } from "@chakra-ui/icons"; 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 { BiRename } from "react-icons/bi"; import { IoCloudUploadOutline } from "react-icons/io5"; 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 @@ -52,7 +46,7 @@ import { outfitStatesAreEqual } from "./useOutfitState"; * to have extra padding. Essentially: while the Items _do_ stretch out the * 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; return ( @@ -62,6 +56,7 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) { @@ -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, * if the user can save this outfit. If not, this is empty! */ -function OutfitSavingIndicator({ outfitState, dispatchToOutfit }) { +function OutfitSavingIndicator({ outfitSaving }) { const { canSaveOutfit, isNewOutfit, @@ -493,7 +265,7 @@ function OutfitSavingIndicator({ outfitState, dispatchToOutfit }) { latestVersionIsSaved, saveError, saveOutfit, - } = useOutfitSaving(outfitState, dispatchToOutfit); + } = outfitSaving; 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! * It also contains the outfit menu, for saving etc. */ -function OutfitHeading({ outfitState, dispatchToOutfit }) { +function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { return ( // The Editable wraps everything, including the menu, because the menu has // a Rename option. @@ -612,10 +384,7 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) { - + diff --git a/src/app/WardrobePage/index.js b/src/app/WardrobePage/index.js index b21b982..f01acf9 100644 --- a/src/app/WardrobePage/index.js +++ b/src/app/WardrobePage/index.js @@ -4,6 +4,7 @@ import { loadable } from "../util"; import ItemsAndSearchPanels from "./ItemsAndSearchPanels"; import SupportOnly from "./support/SupportOnly"; +import useOutfitSaving from "./useOutfitSaving"; import useOutfitState, { OutfitStateContext } from "./useOutfitState"; import { usePageTitle } from "../util"; import WardrobePageLayout from "./WardrobePageLayout"; @@ -26,6 +27,11 @@ function WardrobePage() { const toast = useToast(); 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"); // TODO: I haven't found a great place for this error UI yet, and this case @@ -64,6 +70,7 @@ function WardrobePage() { } diff --git a/src/app/WardrobePage/useOutfitSaving.js b/src/app/WardrobePage/useOutfitSaving.js new file mode 100644 index 0000000..e0c754e --- /dev/null +++ b/src/app/WardrobePage/useOutfitSaving.js @@ -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;