impress/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js

251 lines
8.3 KiB
JavaScript
Raw Normal View History

import React from "react";
import { useToast } from "@chakra-ui/react";
import { useLocation, useNavigate } 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 { pathname } = useLocation();
const navigate = useNavigate();
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);
// Users can delete their own outfits too. The logic is slightly different
// than for saving, because you can save an outfit that hasn't been saved
// yet, but you can't delete it.
const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
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 = [], { readField }) => {
const isAlreadyInList = existingOutfitRefs.some(
(ref) => readField("id", ref) === outfit.id
);
if (isAlreadyInList) {
return existingOutfitRefs;
}
const newOutfitRef = cache.writeFragment({
data: outfit,
fragment: gql`
fragment NewOutfit on Outfit {
id
}
`,
});
return [...existingOutfitRefs, newOutfitRef];
},
},
});
// Also, send a `rename` action, if this is still the current outfit,
// and the server renamed it (e.g. "Untitled outfit (1)"). (It's
// tempting to do a full reset, in case the server knows something we
// don't, but we don't want to clobber changes the user made since
// starting the save!)
if (outfit.id === outfitState.id && outfit.name !== outfitState.name) {
dispatchToOutfit({
type: "rename",
outfitName: outfit.name,
});
}
},
}
);
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.
if (pathname !== `/outfits/[outfitId]`) {
navigate(`/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, pathname, navigate, 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,
canDeleteOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
saveError,
saveOutfit,
};
}
export default useOutfitSaving;