impress/app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js
Emi Matchu 578528f468 Better handling for items in different zones with the same name
Specifically, I was looking at the new "Stormy Cloud Kacheek" items,
and was surprised to find that, in the outfit editor, they all get
grouped under "Markings" (and therefore the UI treats them as
mutually-exclusive via hidden radio button and only bolds one at a
time), but they aren't actually conflicting because they occupy
different zones named "Markings".

In this change, we make the zone groups actually just be *by zone*
rather than jumbling all of the zones with the same label together; but
in most cases, we still keep the same simplified display. In the case
of the "Stormy Cloud Kacheek" items though, we now get a few groups:
`Glasses`, `Markings (#6)`, and `Markings (#16)`. Glasses is chosen
by coincidence because it's the first zone label for that item
alphabetically (even though that item also occupies a third "Markings"
zone), and then the other two know to disambiguate from each other.

There's an opportunity here to cheat things further, like to
*intentionally* select items like "Glasses" that are less ambiguous
when possible. I'm not aware of enough other cases like this for that
to really matter, though, so I'm just leaving it as-is!

I tested this a *bit* on other outfits, and everything looked fine at
a glance, so I'm just moving forward—but I'll make an announcement to
ask people to help take a look!
2024-02-01 03:14:00 -08:00

532 lines
16 KiB
JavaScript

import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Editable,
EditablePreview,
EditableInput,
Flex,
IconButton,
Skeleton,
Tooltip,
VisuallyHidden,
Menu,
MenuButton,
MenuList,
MenuItem,
Portal,
Button,
Spinner,
useColorModeValue,
Modal,
ModalContent,
ModalOverlay,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
ModalCloseButton,
} from "@chakra-ui/react";
import {
CheckIcon,
DeleteIcon,
EditIcon,
QuestionIcon,
WarningTwoIcon,
} from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import { Delay, ErrorMessage, 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 { buildOutfitUrl } from "./useOutfitState";
import { useDeleteOutfitMutation } from "../loaders/outfits";
/**
* ItemsPanel shows the items in the current outfit, and lets the user toggle
* between them! It also shows an editable outfit name, to help set context.
*
* Note that this component provides an effective 1 unit of padding around
* itself, which is uncommon in this app: we usually prefer to let parents
* control the spacing!
*
* This is because Item has padding, but it's generally not visible; so, to
* *look* aligned with the other elements like the headings, the headings need
* 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, outfitSaving, loading, dispatchToOutfit }) {
const { zonesAndItems, incompatibleItems } = outfitState;
return (
<ClassNames>
{({ css }) => (
<Box>
<Box px="1">
<OutfitHeading
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
{loading ? (
<ItemZoneGroupsSkeleton
itemCount={outfitState.allItemIds.length}
/>
) : (
<TransitionGroup component={null}>
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
<CSSTransition
key={zoneId}
{...fadeOutAndRollUpTransition(css)}
>
<ItemZoneGroup
zoneLabel={zoneLabel}
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</CSSTransition>
))}
{incompatibleItems.length > 0 && (
<ItemZoneGroup
zoneLabel="Incompatible"
afterHeader={
<Tooltip
label="These items don't fit this pet"
placement="top"
openDelay={100}
>
<QuestionIcon fontSize="sm" />
</Tooltip>
}
items={incompatibleItems}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
isDisabled
/>
)}
</TransitionGroup>
)}
</Flex>
</Box>
)}
</ClassNames>
);
}
/**
* ItemZoneGroup shows the items for a particular zone, and lets the user
* toggle between them.
*
* For each item, it renders a <label> with a visually-hidden radio button and
* the Item component (which will visually reflect the radio's state). This
* makes the list screen-reader- and keyboard-accessible!
*/
function ItemZoneGroup({
zoneLabel,
items,
outfitState,
dispatchToOutfit,
isDisabled = false,
afterHeader = null,
}) {
// onChange is fired when the radio button becomes checked, not unchecked!
const onChange = (e) => {
const itemId = e.target.value;
dispatchToOutfit({ type: "wearItem", itemId });
};
// Clicking the radio button when already selected deselects it - this is how
// you can select none!
const onClick = (e) => {
const itemId = e.target.value;
if (outfitState.wornItemIds.includes(itemId)) {
// We need the event handler to finish before this, so that simulated
// events don't just come back around and undo it - but we can't just
// solve that with `preventDefault`, because it breaks the radio's
// intended visual updates when we unwear. So, we `setTimeout` to do it
// after all event handlers resolve!
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
}
};
const onRemove = React.useCallback(
(itemId) => {
dispatchToOutfit({ type: "removeItem", itemId });
},
[dispatchToOutfit],
);
return (
<ClassNames>
{({ css }) => (
<Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2>
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => {
const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = (
<Item
item={item}
itemNameId={itemNameId}
isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id)
}
isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove}
isDisabled={isDisabled}
/>
);
return (
<CSSTransition
key={item.id}
{...fadeOutAndRollUpTransition(css)}
>
{isDisabled ? (
itemNode
) : (
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={itemNameId}
name={zoneLabel}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onClick}
onKeyUp={(e) => {
if (e.key === " ") {
onClick(e);
}
}}
/>
{itemNode}
</label>
)}
</CSSTransition>
);
})}
</TransitionGroup>
</ItemListContainer>
</Box>
)}
</ClassNames>
);
}
/**
* ItemZoneGroupSkeletons is a placeholder for when the items are loading.
*
* We try to match the approximate size of the incoming data! This is
* especially nice for when you start with a fresh pet from the homepage, so
* we don't show skeleton items that just clear away!
*/
function ItemZoneGroupsSkeleton({ itemCount }) {
const groups = [];
for (let i = 0; i < itemCount; i++) {
// NOTE: I initially wrote this to return groups of 3, which looks good for
// outfit shares I think, but looks bad for pet loading... once shares
// become a more common use case, it might be useful to figure out how
// to differentiate these cases and show 1-per-group for pets, but
// maybe more for built outfits?
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
}
return groups;
}
/**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/
function ItemZoneGroupSkeleton({ itemCount }) {
return (
<Box mb="10">
<Delay>
<Skeleton
mx="1"
// 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`}
width="12rem"
/>
<ItemListSkeleton count={itemCount} />
</Delay>
</Box>
);
}
/**
* 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({ outfitSaving }) {
const {
canSaveOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
saveError,
saveOutfit,
} = outfitSaving;
const errorTextColor = useColorModeValue("red.600", "red.400");
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 (isSaving) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator"
>
<Spinner
size="xs"
marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saving
</Flex>
);
}
if (latestVersionIsSaved) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saved-indicator"
>
<CheckIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saved
</Flex>
);
}
if (saveError) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-save-error-indicator"
color={errorTextColor}
>
<WarningTwoIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Error saving
</Flex>
);
}
// The most common way we'll hit this null is when the outfit is changing,
// but the debouncing isn't done yet, so it's not saving yet.
return null;
}
/**
* OutfitHeading is an editable outfit name, as a big pretty page heading!
* It also contains the outfit menu, for saving etc.
*/
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
const { canDeleteOutfit } = outfitSaving;
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
return (
// The Editable wraps everything, including the menu, because the menu has
// a Rename option.
<Editable
// Make sure not to ever pass `undefined` into here, or else the
// component enters uncontrolled mode, and changing the value
// later won't fix it!
value={outfitState.name || ""}
placeholder="Untitled outfit"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ onEdit }) => (
<Flex align="center" marginBottom="6">
<Box>
<Box role="group" d="inline-block" position="relative" width="100%">
<Heading1>
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
<EditableInput lineHeight="48px" />
</Heading1>
</Box>
</Box>
<Box width="4" flex="1 0 auto" />
<Box flex="0 0 auto">
<OutfitSavingIndicator outfitSaving={outfitSaving} />
</Box>
<Box width="2" />
<Menu placement="bottom-end">
<MenuButton
as={IconButton}
variant="ghost"
icon={<MdMoreVert />}
aria-label="Outfit menu"
borderRadius="full"
fontSize="24px"
opacity="0.8"
/>
<Portal>
<MenuList>
{outfitState.id && (
<MenuItem
icon={<EditIcon />}
as="a"
href={outfitCopyUrl}
target="_blank"
>
Edit a copy
</MenuItem>
)}
<MenuItem
icon={<BiRename />}
onClick={() => {
// Start the rename after a tick, so finishing up the click
// won't just immediately remove focus from the Editable.
setTimeout(onEdit, 0);
}}
>
Rename
</MenuItem>
{canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} />
)}
</MenuList>
</Portal>
</Menu>
</Flex>
)}
</Editable>
);
}
function DeleteOutfitMenuItem({ outfitState }) {
const { id, name } = outfitState;
const { isOpen, onOpen, onClose } = useDisclosure();
const { status, error, mutateAsync } = useDeleteOutfitMutation();
return (
<>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete
</MenuItem>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton />
<ModalBody>
We'll delete this data and remove it from your list of outfits.
Links and image embeds pointing to this outfit will break. Is that
okay?
{status === "error" && (
<ErrorMessage marginTop="1em">
Error deleting outfit: "{error.message}". Try again?
</ErrorMessage>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" />
<Button
colorScheme="red"
onClick={() =>
mutateAsync(id)
.then(() => {
window.location = "/your-outfits";
})
.catch((e) => {
/* handled in error UI */
})
}
// We continue to show the loading spinner in the success case,
// while we redirect away!
isLoading={status === "pending" || status === "success"}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
/**
* fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the
* fade-out and height decrease when an Item or ItemZoneGroup is removed.
*
* Note that this _cannot_ be implemented as a wrapper component that returns a
* CSSTransition. This is because the CSSTransition must be the direct child of
* the TransitionGroup, and a wrapper breaks the parent-child relationship.
*
* See react-transition-group docs for more info!
*/
const fadeOutAndRollUpTransition = (css) => ({
classNames: css`
&-exit {
opacity: 1;
height: auto;
}
&-exit-active {
opacity: 0;
height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
transition: all 0.5s;
}
`,
timeout: 500,
onExit: (e) => {
e.style.height = e.offsetHeight + "px";
},
});
export default ItemsPanel;