diff --git a/src/app/UserItemListPage.js b/src/app/UserItemListPage.js index bb7d6c4..7ed2ba5 100644 --- a/src/app/UserItemListPage.js +++ b/src/app/UserItemListPage.js @@ -407,7 +407,8 @@ export function ClosetListContents({ {itemsToShow.length > 0 ? ( @@ -447,11 +448,17 @@ export function ClosetListContents({ const ITEM_CARD_WIDTH = 112 + 16; const ITEM_CARD_HEIGHT = 171 + 16; -function ClosetItemList({ items, canEdit, tradeMatchingMode }) { +function ClosetItemList({ + itemsToShow, + closetList, + canEdit, + tradeMatchingMode, +}) { const renderItem = (item) => ( @@ -459,10 +466,10 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) { // For small lists, we don't bother to virtualize, because it slows down // scrolling! (This helps a lot on the lists index page.) - if (items.length < 30) { + if (itemsToShow.length < 30) { return ( - {items.map((item) => ( + {itemsToShow.map((item) => ( {renderItem(item)} ))} @@ -475,7 +482,7 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) { {({ width }) => { const numItemsPerRow = Math.floor(width / ITEM_CARD_WIDTH); - const numRows = Math.ceil(items.length / numItemsPerRow); + const numRows = Math.ceil(itemsToShow.length / numItemsPerRow); return (
@@ -487,7 +494,7 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) { rowHeight={ITEM_CARD_HEIGHT} rowRenderer={({ index: rowIndex, key, style }) => { const firstItemIndex = rowIndex * numItemsPerRow; - const itemsForRow = items.slice( + const itemsForRow = itemsToShow.slice( firstItemIndex, firstItemIndex + numItemsPerRow ); @@ -522,7 +529,45 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) { ); } -function ClosetListItemCard({ item, canEdit, tradeMatchingMode }) { +function ClosetListItemCard({ item, closetList, canEdit, tradeMatchingMode }) { + const toast = useToast(); + + const [sendRemoveMutation] = useMutation( + gql` + mutation ClosetListItemCard_RemoveItem($listId: ID!, $itemId: ID!) { + removeItemFromClosetList(listId: $listId, itemId: $itemId) { + id + items { + id + } + } + } + `, + { context: { sendAuth: true } } + ); + + const onRemove = React.useCallback(() => { + sendRemoveMutation({ + variables: { itemId: item.id, listId: closetList.id }, + optimisticResponse: { + removeItemFromClosetList: { + id: closetList.id, + __typename: "ClosetList", + items: closetList.items + .filter(({ id }) => id !== item.id) + .map(({ id }) => ({ id, __typename: "Item" })), + }, + }, + }).catch((error) => { + console.error(error); + toast({ + status: "error", + title: `Oops, we couldn't remove "${item.name}" from this list!`, + description: "Check your connection and try again. Sorry!", + }); + }); + }, [sendRemoveMutation, item, closetList, toast]); + return ( alert("TODO")} + onRemove={onRemove} /> ); } diff --git a/src/app/apolloClient.js b/src/app/apolloClient.js index 9bf6c5d..d66be15 100644 --- a/src/app/apolloClient.js +++ b/src/app/apolloClient.js @@ -149,6 +149,13 @@ const typePolicies = { }, }, }, + + ClosetList: { + fields: { + // When loading the updated contents of a list, replace it entirely. + items: { merge: false }, + }, + }, }; const httpLink = createHttpLink({ uri: "/api/graphql" }); diff --git a/src/app/components/SquareItemCard.js b/src/app/components/SquareItemCard.js index 2141d90..af41c97 100644 --- a/src/app/components/SquareItemCard.js +++ b/src/app/components/SquareItemCard.js @@ -97,15 +97,17 @@ function SquareItemCard({ right: 0; top: 0; transform: translate(50%, -50%); + z-index: 1; + /* Apply some padding, so accidental clicks around the button * don't click the link instead, or vice-versa! */ - padding: 0.5em; + padding: 0.75em; opacity: 0; [role="group"]:hover &, [role="group"]:focus-within &, &:hover, - &:focus { + &:focus-within { opacity: 1; } `} diff --git a/src/server/loaders.js b/src/server/loaders.js index 18be3be..61c59dc 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -30,6 +30,33 @@ const buildClosetHangersForListLoader = (db) => ); }); +const buildClosetHangersForDefaultListLoader = (db) => + new DataLoader(async (userIdAndOwnsOrWantsItemsPairs) => { + const conditions = userIdAndOwnsOrWantsItemsPairs + .map((_) => `(user_id = ? AND owned = ? AND list_id IS NULL)`) + .join(" OR "); + const values = userIdAndOwnsOrWantsItemsPairs + .map(({ userId, ownsOrWantsItems }) => [ + userId, + ownsOrWantsItems === "OWNS", + ]) + .flat(); + const [rows] = await db.execute( + `SELECT * FROM closet_hangers WHERE ${conditions}`, + values + ); + + const entities = rows.map(normalizeRow); + + return userIdAndOwnsOrWantsItemsPairs.map(({ userId, ownsOrWantsItems }) => + entities.filter( + (e) => + e.userId === userId && + Boolean(e.owned) === (ownsOrWantsItems === "OWNS") + ) + ); + }); + const buildColorLoader = (db) => { const colorLoader = new DataLoader(async (colorIds) => { const qs = colorIds.map((_) => "?").join(","); @@ -1393,6 +1420,9 @@ function buildLoaders(db) { loaders.closetListLoader = buildClosetListLoader(db); loaders.closetHangersForListLoader = buildClosetHangersForListLoader(db); + loaders.closetHangersForDefaultListLoader = buildClosetHangersForDefaultListLoader( + db + ); loaders.colorLoader = buildColorLoader(db); loaders.colorTranslationLoader = buildColorTranslationLoader(db); loaders.itemLoader = buildItemLoader(db); diff --git a/src/server/types/ClosetList.js b/src/server/types/ClosetList.js index 91df135..7934ad3 100644 --- a/src/server/types/ClosetList.js +++ b/src/server/types/ClosetList.js @@ -103,9 +103,13 @@ const resolvers = { }, items: async ( - { id, items: precomputedItems }, + { isDefaultList, id, userId, ownsOrWantsItems, items: precomputedItems }, _, - { itemLoader, closetHangersForListLoader } + { + itemLoader, + closetHangersForListLoader, + closetHangersForDefaultListLoader, + } ) => { // HACK: When called from User.js, for fetching all of a user's lists at // once, this is provided in the returned object. Just use it! @@ -114,8 +118,12 @@ const resolvers = { return precomputedItems; } - // TODO: Support the not-in-a-list case! - const closetHangers = await closetHangersForListLoader.load(id); + const closetHangers = isDefaultList + ? await closetHangersForDefaultListLoader.load({ + userId, + ownsOrWantsItems, + }) + : await closetHangersForListLoader.load(id); const itemIds = closetHangers.map((h) => h.itemId); const items = await itemLoader.loadMany(itemIds); diff --git a/src/server/types/Item.js b/src/server/types/Item.js index c1a36f0..1722dc9 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -266,6 +266,8 @@ const typeDefs = gql` addToItemsCurrentUserWants(itemId: ID!): Item removeFromItemsCurrentUserWants(itemId: ID!): Item + + removeItemFromClosetList(listId: ID!, itemId: ID!): ClosetList } `; @@ -952,7 +954,87 @@ const resolvers = { return { id: itemId }; }, + removeItemFromClosetList: async ( + _, + { listId, itemId }, + { currentUserId, db, closetListLoader } + ) => { + const closetListRef = await loadClosetListOrDefaultList( + listId, + closetListLoader + ); + if (closetListRef == null) { + throw new Error(`list ${listId} not found`); + } + + if (closetListRef.userId !== currentUserId) { + throw new Error(`current user does not own this list`); + } + + const listMatcherCondition = closetListRef.isDefaultList + ? `(user_id = ? AND owned = ? AND list_id IS NULL)` + : `(list_id = ?)`; + const listMatcherValues = closetListRef.isDefaultList + ? [closetListRef.userId, closetListRef.ownsOrWantsItems === "OWNS"] + : [closetListRef.id]; + + await db.query( + ` + DELETE FROM closet_hangers + WHERE ${listMatcherCondition} AND item_id = ? LIMIT 1; + `, + [...listMatcherValues, itemId] + ); + + return closetListRef; + }, }, }; +// This matches the ID that we return from ClosetList for the default list. +const DEFAULT_LIST_ID_PATTERN = /user-(.+?)-default-list-(OWNS|WANTS)/; + +/** + * Given a list ID returned from ClosetList (which the client might've passed + * back to us in a mutation), parse it as either a *real* list ID, or the + * placeholder ID we provide to "default" lists; and return the fields that + * our ClosetList resolver expects in order to return the correct list. (That + * is: `isDefaultList`, `id` (perhaps null), `userId`, and `ownsOrWantsItems`. + * The resolver doesn't need all of the fields in both cases, but we return + * both in case you want to use them for other things, e.g. checking the + * `userId`!) + * + * (Or return null, if the list ID does not correspond to a default list *or* a + * real list in the database.) + */ +async function loadClosetListOrDefaultList(listId, closetListLoader) { + if (listId == null) { + return null; + } + + const defaultListMatch = listId.match(DEFAULT_LIST_ID_PATTERN); + if (defaultListMatch) { + const userId = defaultListMatch[1]; + const ownsOrWantsItems = defaultListMatch[2]; + return { + isDefaultList: true, + id: null, + userId, + ownsOrWantsItems, + }; + } + + const closetList = await closetListLoader.load(listId); + if (closetList) { + return { + isDefaultList: false, + id: closetList.id, + userId: closetList.userId, + ownsOrWantsItems: closetList.hangersOwned ? "OWNS" : "WANTS", + }; + } + + return null; +} + module.exports = { typeDefs, resolvers };