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:
parent
dc6d5b5851
commit
99e0fdbf59
2 changed files with 135 additions and 41 deletions
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue