use item data to detect conflicts

This commit is contained in:
Matt Dunn-Rankin 2020-04-24 18:39:38 -07:00
parent d0c698bf18
commit abfe854756
6 changed files with 154 additions and 66 deletions

View file

@ -18,6 +18,7 @@
"dataloader": "^2.0.0", "dataloader": "^2.0.0",
"emotion-theming": "^10.0.27", "emotion-theming": "^10.0.27",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"immer": "^6.0.3",
"mysql2": "^2.1.0", "mysql2": "^2.1.0",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",

View file

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Box, Image, PseudoBox, Stack, Skeleton } from "@chakra-ui/core"; import { Box, Image, PseudoBox, Stack, Skeleton } from "@chakra-ui/core";
function ItemList({ items, wornItemIds, onWearItem }) { function ItemList({ items, wornItemIds, dispatchToOutfit }) {
return ( return (
<Stack spacing="3"> <Stack spacing="3">
{items.map((item) => ( {items.map((item) => (
@ -9,7 +9,7 @@ function ItemList({ items, wornItemIds, onWearItem }) {
<Item <Item
item={item} item={item}
isWorn={wornItemIds.includes(item.id)} isWorn={wornItemIds.includes(item.id)}
onWear={() => onWearItem(item.id)} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </Box>
))} ))}
@ -33,14 +33,14 @@ function ItemListSkeleton() {
); );
} }
function Item({ item, isWorn, onWear }) { function Item({ item, isWorn, dispatchToOutfit }) {
return ( return (
<PseudoBox <PseudoBox
role="group" role="group"
d="flex" d="flex"
alignItems="center" alignItems="center"
cursor="pointer" cursor="pointer"
onClick={onWear} onClick={() => dispatchToOutfit({ type: "wearItem", itemId: item.id })}
> >
<ItemThumbnail src={item.thumbnailUrl} isWorn={isWorn} /> <ItemThumbnail src={item.thumbnailUrl} isWorn={isWorn} />
<Box width="3" /> <Box width="3" />

View file

@ -27,7 +27,7 @@ import OutfitPreview from "./OutfitPreview";
import { Delay } from "./util"; import { Delay } from "./util";
function WardrobePage() { function WardrobePage() {
const { loading, error, data, wearItem } = useOutfitState(); const { loading, error, data, dispatch: dispatchToOutfit } = useOutfitState();
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const toast = useToast(); const toast = useToast();
@ -69,8 +69,8 @@ function WardrobePage() {
<Box gridArea="outfit" backgroundColor="gray.900"> <Box gridArea="outfit" backgroundColor="gray.900">
<OutfitPreview <OutfitPreview
itemIds={data.wornItemIds} itemIds={data.wornItemIds}
speciesId="54" speciesId={data.speciesId}
colorId="75" colorId={data.colorId}
/> />
</Box> </Box>
<Box gridArea="search" boxShadow="sm"> <Box gridArea="search" boxShadow="sm">
@ -84,13 +84,13 @@ function WardrobePage() {
<SearchPanel <SearchPanel
query={searchQuery} query={searchQuery}
wornItemIds={data.wornItemIds} wornItemIds={data.wornItemIds}
onWearItem={wearItem} dispatchToOutfit={dispatchToOutfit}
/> />
) : ( ) : (
<ItemsPanel <ItemsPanel
zonesAndItems={data.zonesAndItems} zonesAndItems={data.zonesAndItems}
loading={loading} loading={loading}
onWearItem={wearItem} dispatchToOutfit={dispatchToOutfit}
/> />
)} )}
</Box> </Box>
@ -135,7 +135,7 @@ function SearchToolbar({ query, onChange }) {
); );
} }
function SearchPanel({ query, wornItemIds, onWearItem }) { function SearchPanel({ query, wornItemIds, dispatchToOutfit }) {
const { loading, error, itemsById } = useItemData(ITEMS.map((i) => i.id)); const { loading, error, itemsById } = useItemData(ITEMS.map((i) => i.id));
const normalize = (s) => s.toLowerCase(); const normalize = (s) => s.toLowerCase();
@ -152,13 +152,19 @@ function SearchPanel({ query, wornItemIds, onWearItem }) {
error={error} error={error}
results={results} results={results}
wornItemIds={wornItemIds} wornItemIds={wornItemIds}
onWearItem={onWearItem} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </Box>
); );
} }
function SearchResults({ loading, error, results, wornItemIds, onWearItem }) { function SearchResults({
loading,
error,
results,
wornItemIds,
dispatchToOutfit,
}) {
if (loading) { if (loading) {
return <ItemListSkeleton />; return <ItemListSkeleton />;
} }
@ -191,12 +197,12 @@ function SearchResults({ loading, error, results, wornItemIds, onWearItem }) {
<ItemList <ItemList
items={results} items={results}
wornItemIds={wornItemIds} wornItemIds={wornItemIds}
onWearItem={onWearItem} dispatchToOutfit={dispatchToOutfit}
/> />
); );
} }
function ItemsPanel({ zonesAndItems, loading, onWearItem }) { function ItemsPanel({ zonesAndItems, loading, dispatchToOutfit }) {
return ( return (
<Box color="green.800"> <Box color="green.800">
<OutfitHeading /> <OutfitHeading />
@ -217,7 +223,7 @@ function ItemsPanel({ zonesAndItems, loading, onWearItem }) {
<ItemList <ItemList
items={items} items={items}
wornItemIds={[wornItemId]} wornItemIds={[wornItemId]}
onWearItem={onWearItem} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </Box>
))} ))}

View file

@ -3,18 +3,28 @@ import { useQuery } from "@apollo/react-hooks";
import { ITEMS } from "./data"; import { ITEMS } from "./data";
function useItemData(itemIds) { function useItemData(itemIds, speciesId, colorId) {
const { loading, error, data } = useQuery( const { loading, error, data } = useQuery(
gql` gql`
query($itemIds: [ID!]!) { query($itemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) {
items(ids: $itemIds) { items(ids: $itemIds) {
id id
name name
thumbnailUrl thumbnailUrl
# This is used for wearItem actions, to resolve conflicts. We don't
# use it directly; we just expect it to be in the cache!
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
layers {
zone {
id
}
}
}
} }
} }
`, `,
{ variables: { itemIds } } { variables: { itemIds, speciesId, colorId } }
); );
const items = (data && data.items) || []; const items = (data && data.items) || [];

View file

@ -1,9 +1,16 @@
import React from "react"; import React from "react";
import gql from "graphql-tag";
import produce, { enableMapSet } from "immer";
import { useApolloClient } from "@apollo/react-hooks";
import useItemData from "./useItemData"; import useItemData from "./useItemData";
enableMapSet();
function useOutfitState() { function useOutfitState() {
const [wornItemIds, setWornItemIds] = React.useState([ const apolloClient = useApolloClient();
const [state, dispatch] = React.useReducer(outfitStateReducer(apolloClient), {
wornItemIds: new Set([
"38913", "38913",
"38911", "38911",
"38912", "38912",
@ -12,46 +19,24 @@ function useOutfitState() {
"37229", "37229",
"43014", "43014",
"43397", "43397",
]); ]),
const [closetedItemIds, setClosetedItemIds] = React.useState(["74166"]); closetedItemIds: new Set(["74166"]),
speciesId: "54", // Starry
const allItemIds = [...wornItemIds, ...closetedItemIds]; colorId: "75", // Zafara
const { loading, error, itemsById } = useItemData(allItemIds);
const wearItem = React.useCallback(
(itemIdToAdd) => {
if (wornItemIds.includes(itemIdToAdd)) {
return;
}
let newWornItemIds = wornItemIds;
let newClosetedItemIds = closetedItemIds;
const itemToAdd = itemsById[itemIdToAdd];
// Move the item out of the closet.
newClosetedItemIds = newClosetedItemIds.filter(
(id) => id !== itemIdToAdd
);
// Move conflicting items to the closet.
const conflictingItemIds = newWornItemIds.filter((wornItemId) => {
const wornItem = itemsById[wornItemId];
return wornItem.zoneName === itemToAdd.zoneName;
}); });
newWornItemIds = newWornItemIds.filter(
(id) => !conflictingItemIds.includes(id)
);
newClosetedItemIds = [...newClosetedItemIds, ...conflictingItemIds];
// Add this item to the worn set. const { speciesId, colorId } = state;
newWornItemIds = [...newWornItemIds, itemIdToAdd];
setWornItemIds(newWornItemIds); // It's more convenient to manage these as a Set in state, but most callers
setClosetedItemIds(newClosetedItemIds); // will find it more convenient to access them as arrays! e.g. for `.map()`
}, const wornItemIds = Array.from(state.wornItemIds);
[wornItemIds, closetedItemIds, itemsById] const closetedItemIds = Array.from(state.closetedItemIds);
const allItemIds = [...state.wornItemIds, ...state.closetedItemIds];
const { loading, error, itemsById } = useItemData(
allItemIds,
speciesId,
colorId
); );
const zonesAndItems = getZonesAndItems( const zonesAndItems = getZonesAndItems(
@ -60,9 +45,90 @@ function useOutfitState() {
closetedItemIds closetedItemIds
); );
const data = { zonesAndItems, wornItemIds }; const data = { zonesAndItems, wornItemIds, speciesId, colorId };
return { loading, error, data, wearItem }; return { loading, error, data, dispatch };
}
const outfitStateReducer = (apolloClient) => (baseState, action) => {
switch (action.type) {
case "wearItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds, speciesId, colorId } = state;
const { itemId } = action;
// Move the item out of the closet.
closetedItemIds.delete(itemId);
// Move conflicting items to the closet.
//
// We do this by looking them up in the Apollo Cache, which is going to
// include the relevant item data because the `useOutfitState` hook
// queries for it!
//
// (It could be possible to mess up the timing by taking an action
// while worn items are still partially loading, but I think it would
// require a pretty weird action sequence to make that happen... like,
// doing a search and it loads before the worn item data does? Anyway,
// Apollo will throw in that case, which should just essentially reject
// the action.)
const conflictingIds = findItemConflicts(itemId, state, apolloClient);
for (const conflictingId of conflictingIds) {
wornItemIds.delete(conflictingId);
closetedItemIds.add(conflictingId);
}
// Add this item to the worn set.
wornItemIds.add(itemId);
});
default:
throw new Error(`unexpected action ${action}`);
}
};
function findItemConflicts(itemIdToAdd, state, apolloClient) {
const { wornItemIds, speciesId, colorId } = state;
const { items } = apolloClient.readQuery({
query: gql`
query($itemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) {
items(ids: $itemIds) {
id
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
layers {
zone {
id
}
}
}
}
}
`,
variables: {
itemIds: [itemIdToAdd, ...wornItemIds],
speciesId,
colorId,
},
});
const itemToAdd = items.find((i) => i.id === itemIdToAdd);
const itemToAddZoneIds = itemToAdd.appearanceOn.layers.map((l) => l.zone.id);
const wornItems = Array.from(wornItemIds).map((id) =>
items.find((i) => i.id === id)
);
const conflictingIds = [];
for (const wornItem of wornItems) {
const wornItemZoneIds = wornItem.appearanceOn.layers.map((l) => l.zone.id);
const hasConflict = wornItemZoneIds.some((zid) =>
itemToAddZoneIds.includes(zid)
);
if (hasConflict) {
conflictingIds.push(wornItem.id);
}
}
return conflictingIds;
} }
function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) { function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {

View file

@ -6109,6 +6109,11 @@ immer@1.10.0:
resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
immer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/immer/-/immer-6.0.3.tgz#94d5051cd724668160a900d66d85ec02816f29bd"
integrity sha512-12VvNrfSrXZdm/BJgi/KDW2soq5freVSf3I1+4CLunUM8mAGx2/0Njy0xBVzi5zewQZiwM7z1/1T+8VaI7NkmQ==
import-cwd@^2.0.0: import-cwd@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"