Add indicator for whether changes are saved

Now, when viewing a saved outfit that you own, you'll see a "Saved" indicator if it matches the version on the server, or a temporary UI of "Not saved" and a tooltip if not.

Auto-save coming next!
This commit is contained in:
Emi Matchu 2021-04-22 02:35:59 -07:00
parent dc6d5b5851
commit 99e0fdbf59
2 changed files with 135 additions and 41 deletions

View file

@ -17,8 +17,19 @@ import {
Portal, Portal,
Button, Button,
useToast, useToast,
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
PopoverBody,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { EditIcon, QuestionIcon } from "@chakra-ui/icons"; import {
CheckIcon,
EditIcon,
ExternalLinkIcon,
QuestionIcon,
WarningIcon,
} 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 { useHistory } from "react-router-dom";
@ -30,6 +41,7 @@ import { MdMoreVert } from "react-icons/md";
import useCurrentUser from "../components/useCurrentUser"; import useCurrentUser from "../components/useCurrentUser";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useMutation } from "@apollo/client"; 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
@ -255,16 +267,19 @@ function useOutfitSaving(outfitState) {
const history = useHistory(); const history = useHistory();
const toast = useToast(); const toast = useToast();
// Whether this outfit has *ever* been saved, vs a brand-new local outfit. // Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
const hasBeenSaved = Boolean(outfitState.id); // to the server.
const isNewOutfit = outfitState.id == null;
// Whether this outfit's latest local changes have been saved to the server.
const latestVersionIsSaved =
outfitState.savedOutfitState &&
outfitStatesAreEqual(outfitState, outfitState.savedOutfitState);
// Only logged-in users can save outfits - and they can only save new outfits, // Only logged-in users can save outfits - and they can only save new outfits,
// or outfits they created. // or outfits they created.
const canSaveOutfit = const canSaveOutfit =
isLoggedIn && isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
(!hasBeenSaved || outfitState.creator?.id === currentUserId) &&
// TODO: Add support for updating outfits
!hasBeenSaved;
const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation( const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation(
gql` gql`
@ -365,18 +380,104 @@ function useOutfitSaving(outfitState) {
return { return {
canSaveOutfit, canSaveOutfit,
isNewOutfit,
isSaving, isSaving,
latestVersionIsSaved,
saveOutfit, 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 }) {
const {
canSaveOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
saveOutfit,
} = useOutfitSaving(outfitState);
if (!canSaveOutfit) {
return null;
}
if (isNewOutfit) {
return (
<Button
variant="outline"
size="sm"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={
<Box
// Adjust the visual balance toward the cloud
marginBottom="-2px"
>
<IoCloudUploadOutline />
</Box>
}
onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button"
>
Save
</Button>
);
}
if (latestVersionIsSaved) {
return (
<Flex align="center" fontSize="xs">
<CheckIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saved
</Flex>
);
}
return (
<Popover trigger="hover">
<PopoverTrigger>
<Flex align="center" fontSize="xs" tabIndex="0">
<WarningIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Not saved
</Flex>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverBody>
We're still working on this! For now, use{" "}
<Box
as="a"
href={`https://impress.openneo.net/outfits/${outfitState.id}`}
target="_blank"
>
<Box as="span" textDecoration="underline">
Classic DTI
</Box>
<ExternalLinkIcon marginLeft="1" marginTop="-3px" fontSize="sm" />
</Box>{" "}
to save existing outfits.
</PopoverBody>
</PopoverContent>
</Popover>
);
}
/** /**
* 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, dispatchToOutfit }) {
const { canSaveOutfit, isSaving, saveOutfit } = useOutfitSaving(outfitState);
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.
@ -401,30 +502,10 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
</Box> </Box>
</Box> </Box>
<Box width="4" flex="1 0 auto" /> <Box width="4" flex="1 0 auto" />
{canSaveOutfit && ( <Box flex="0 0 auto">
<> <OutfitSavingIndicator outfitState={outfitState} />
<Button
variant="outline"
size="sm"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={
<Box
// Adjust the visual balance toward the cloud
marginBottom="-2px"
>
<IoCloudUploadOutline />
</Box> </Box>
}
onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button"
>
Save
</Button>
<Box width="2" /> <Box width="2" />
</>
)}
<Menu placement="bottom-end"> <Menu placement="bottom-end">
<MenuButton <MenuButton
as={IconButton} as={IconButton}

View file

@ -231,6 +231,7 @@ function useOutfitState() {
pose, pose,
appearanceId, appearanceId,
url, url,
savedOutfitState,
}; };
// Keep the URL up-to-date. (We don't listen to it, though 😅) // Keep the URL up-to-date. (We don't listen to it, though 😅)
@ -567,8 +568,19 @@ function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
} }
function buildOutfitUrl(outfitState) { function buildOutfitUrl(outfitState) {
const { id } = outfitState;
const { origin, pathname } = window.location;
if (id) {
return origin + `/outfits/${id}`;
}
return origin + pathname + "?" + buildOutfitQueryString(outfitState);
}
function buildOutfitQueryString(outfitState) {
const { const {
id,
name, name,
speciesId, speciesId,
colorId, colorId,
@ -578,12 +590,6 @@ function buildOutfitUrl(outfitState) {
closetedItemIds, closetedItemIds,
} = outfitState; } = outfitState;
const { origin, pathname } = window.location;
if (id) {
return origin + `/outfits/${id}`;
}
const params = new URLSearchParams({ const params = new URLSearchParams({
name: name || "", name: name || "",
species: speciesId, species: speciesId,
@ -602,7 +608,14 @@ function buildOutfitUrl(outfitState) {
params.append("state", appearanceId); params.append("state", appearanceId);
} }
return origin + pathname + "?" + params.toString(); return params.toString();
}
/**
* Whether the two given outfit states represent identical customizations.
*/
export function outfitStatesAreEqual(a, b) {
return buildOutfitQueryString(a) === buildOutfitQueryString(b);
} }
export default useOutfitState; export default useOutfitState;