From 6efd542f491309f4bde247533c2a5af7119257cf Mon Sep 17 00:00:00 2001 From: Matchu Date: Thu, 30 Sep 2021 19:26:09 -0700 Subject: [PATCH] Wire up the Remove button for item lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Did some stuff in here for parsing the default list ID too. We skipped that when making the new list index page, but now maybe you could reasonably link to the default list? 🤔 not sure it's a huge deal though --- src/app/UserItemListPage.js | 61 ++++++++++++++++++--- src/app/apolloClient.js | 7 +++ src/app/components/SquareItemCard.js | 6 +- src/server/loaders.js | 30 ++++++++++ src/server/types/ClosetList.js | 16 ++++-- src/server/types/Item.js | 82 ++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 14 deletions(-) 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 };