diff --git a/package.json b/package.json index 9b043bf..05ae20f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dataloader": "^2.0.0", "emotion-theming": "^10.0.27", "graphql": "^15.0.0", + "immer": "^6.0.3", "mysql2": "^2.1.0", "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/src/ItemList.js b/src/ItemList.js index b03f63b..24254a6 100644 --- a/src/ItemList.js +++ b/src/ItemList.js @@ -1,7 +1,7 @@ import React from "react"; import { Box, Image, PseudoBox, Stack, Skeleton } from "@chakra-ui/core"; -function ItemList({ items, wornItemIds, onWearItem }) { +function ItemList({ items, wornItemIds, dispatchToOutfit }) { return ( {items.map((item) => ( @@ -9,7 +9,7 @@ function ItemList({ items, wornItemIds, onWearItem }) { onWearItem(item.id)} + dispatchToOutfit={dispatchToOutfit} /> ))} @@ -33,14 +33,14 @@ function ItemListSkeleton() { ); } -function Item({ item, isWorn, onWear }) { +function Item({ item, isWorn, dispatchToOutfit }) { return ( dispatchToOutfit({ type: "wearItem", itemId: item.id })} > diff --git a/src/WardrobePage.js b/src/WardrobePage.js index fd15fc9..f59184c 100644 --- a/src/WardrobePage.js +++ b/src/WardrobePage.js @@ -27,7 +27,7 @@ import OutfitPreview from "./OutfitPreview"; import { Delay } from "./util"; function WardrobePage() { - const { loading, error, data, wearItem } = useOutfitState(); + const { loading, error, data, dispatch: dispatchToOutfit } = useOutfitState(); const [searchQuery, setSearchQuery] = React.useState(""); const toast = useToast(); @@ -69,8 +69,8 @@ function WardrobePage() { @@ -84,13 +84,13 @@ function WardrobePage() { ) : ( )} @@ -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 normalize = (s) => s.toLowerCase(); @@ -152,13 +152,19 @@ function SearchPanel({ query, wornItemIds, onWearItem }) { error={error} results={results} wornItemIds={wornItemIds} - onWearItem={onWearItem} + dispatchToOutfit={dispatchToOutfit} /> ); } -function SearchResults({ loading, error, results, wornItemIds, onWearItem }) { +function SearchResults({ + loading, + error, + results, + wornItemIds, + dispatchToOutfit, +}) { if (loading) { return ; } @@ -191,12 +197,12 @@ function SearchResults({ loading, error, results, wornItemIds, onWearItem }) { ); } -function ItemsPanel({ zonesAndItems, loading, onWearItem }) { +function ItemsPanel({ zonesAndItems, loading, dispatchToOutfit }) { return ( @@ -217,7 +223,7 @@ function ItemsPanel({ zonesAndItems, loading, onWearItem }) { ))} diff --git a/src/useItemData.js b/src/useItemData.js index 307fa0d..a1b63f6 100644 --- a/src/useItemData.js +++ b/src/useItemData.js @@ -3,18 +3,28 @@ import { useQuery } from "@apollo/react-hooks"; import { ITEMS } from "./data"; -function useItemData(itemIds) { +function useItemData(itemIds, speciesId, colorId) { const { loading, error, data } = useQuery( gql` - query($itemIds: [ID!]!) { + query($itemIds: [ID!]!, $speciesId: ID!, $colorId: ID!) { items(ids: $itemIds) { id name 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) || []; diff --git a/src/useOutfitState.js b/src/useOutfitState.js index 77e0e4a..f3a90c8 100644 --- a/src/useOutfitState.js +++ b/src/useOutfitState.js @@ -1,57 +1,42 @@ import React from "react"; +import gql from "graphql-tag"; +import produce, { enableMapSet } from "immer"; +import { useApolloClient } from "@apollo/react-hooks"; import useItemData from "./useItemData"; +enableMapSet(); + function useOutfitState() { - const [wornItemIds, setWornItemIds] = React.useState([ - "38913", - "38911", - "38912", - "37375", - "48313", - "37229", - "43014", - "43397", - ]); - const [closetedItemIds, setClosetedItemIds] = React.useState(["74166"]); + const apolloClient = useApolloClient(); + const [state, dispatch] = React.useReducer(outfitStateReducer(apolloClient), { + wornItemIds: new Set([ + "38913", + "38911", + "38912", + "37375", + "48313", + "37229", + "43014", + "43397", + ]), + closetedItemIds: new Set(["74166"]), + speciesId: "54", // Starry + colorId: "75", // Zafara + }); - const allItemIds = [...wornItemIds, ...closetedItemIds]; + const { speciesId, colorId } = state; - const { loading, error, itemsById } = useItemData(allItemIds); + // It's more convenient to manage these as a Set in state, but most callers + // will find it more convenient to access them as arrays! e.g. for `.map()` + const wornItemIds = Array.from(state.wornItemIds); + const closetedItemIds = Array.from(state.closetedItemIds); - 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. - newWornItemIds = [...newWornItemIds, itemIdToAdd]; - - setWornItemIds(newWornItemIds); - setClosetedItemIds(newClosetedItemIds); - }, - [wornItemIds, closetedItemIds, itemsById] + const allItemIds = [...state.wornItemIds, ...state.closetedItemIds]; + const { loading, error, itemsById } = useItemData( + allItemIds, + speciesId, + colorId ); const zonesAndItems = getZonesAndItems( @@ -60,9 +45,90 @@ function useOutfitState() { 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) { diff --git a/yarn.lock b/yarn.lock index 7eb3aa1..2e57e44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6109,6 +6109,11 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" 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: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"