From e95f6abbe4b30052a32b77ebfd7c2ebdc6a00363 Mon Sep 17 00:00:00 2001 From: Matchu Date: Tue, 30 Nov 2021 16:36:00 -0800 Subject: [PATCH] Closet list dropdowns on item page Oooh this feature is feeling very nice :) :) We hid "not in a list" pretty smoothly I think! A known bug: If you have the item in a list, then click the big colorful button, it will remove the item from *all* lists; and then if you click it again, it will add it to Not in a List. But! The UI will still show the lists it was in before, because we haven't updated the client cache. (It's not that bad in the middle state though, because the list dropdown stuff gets hidden.) --- src/app/ItemPage.js | 249 ++++++++++++++++++++++++++------- src/server/types/ClosetList.js | 42 +++++- src/server/types/Item.js | 146 +++++++++++++++++-- 3 files changed, 364 insertions(+), 73 deletions(-) diff --git a/src/app/ItemPage.js b/src/app/ItemPage.js index 210b102..206f8f8 100644 --- a/src/app/ItemPage.js +++ b/src/app/ItemPage.js @@ -17,6 +17,10 @@ import { Flex, usePrefersReducedMotion, Grid, + Popover, + PopoverContent, + PopoverTrigger, + Checkbox, } from "@chakra-ui/react"; import { CheckIcon, @@ -146,13 +150,17 @@ function ItemPageOwnWantButtons({ itemId }) { query ItemPageOwnWantButtons($itemId: ID!) { item(id: $itemId) { id + name currentUserOwnsThis currentUserWantsThis - currentUserHasInLists { + } + currentUser { + closetLists { id name isDefaultList ownsOrWantsItems + hasItem(itemId: $itemId) } } } @@ -164,11 +172,10 @@ function ItemPageOwnWantButtons({ itemId }) { return {error.message}; } - const closetLists = data?.item?.currentUserHasInLists || []; - const ownedLists = closetLists.filter((cl) => cl.ownsOrWantsItems === "OWNS"); - const wantedLists = closetLists.filter( - (cl) => cl.ownsOrWantsItems === "WANTS" - ); + const closetLists = data?.currentUser?.closetLists || []; + const realLists = closetLists.filter((cl) => !cl.isDefaultList); + const ownedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "OWNS"); + const wantedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "WANTS"); return ( - = 1} + popoverPlacement="bottom-end" /> @@ -196,65 +207,195 @@ function ItemPageOwnWantButtons({ itemId }) { isChecked={data?.item?.currentUserWantsThis} /> - = 1} + popoverPlacement="bottom-start" /> ); } -function ItemPageOwnWantListsButton({ closetLists, isVisible }) { +function ItemPageOwnWantListsDropdown({ + closetLists, + item, + isVisible, + popoverPlacement, +}) { + return ( + + + + + + + + + ); +} + +const ItemPageOwnWantListsDropdownButton = React.forwardRef( + ({ closetLists, isVisible, ...props }, ref) => { + const listsToShow = closetLists.filter((cl) => cl.hasItem); + + let buttonText; + if (listsToShow.length === 1) { + buttonText = `In list: "${listsToShow[0].name}"`; + } else if (listsToShow.length > 1) { + const listNames = listsToShow.map((cl) => `"${cl.name}"`).join(", "); + buttonText = `${listsToShow.length} lists: ${listNames}`; + } else { + buttonText = "Add to list"; + } + + return ( + + ); + } +); + +function ItemPageOwnWantListsDropdownContent({ closetLists, item }) { + return ( + + {closetLists.map((closetList) => ( + + + + ))} + + ); +} + +function ItemPageOwnWantsListsDropdownRow({ closetList, item }) { const toast = useToast(); - const realLists = closetLists.filter((cl) => !cl.isDefaultList); + const [sendAddToListMutation] = useMutation( + gql` + mutation ItemPage_AddToClosetList($listId: ID!, $itemId: ID!) { + addItemToClosetList( + listId: $listId + itemId: $itemId + removeFromDefaultList: true + ) { + id + hasItem(itemId: $itemId) + } + } + `, + { context: { sendAuth: true } } + ); - let buttonText; - if (realLists.length === 1) { - buttonText = `In list: "${realLists[0].name}"`; - } else if (realLists.length > 1) { - const listNames = realLists.map((cl) => `"${cl.name}"`).join(", "); - buttonText = `${realLists.length} lists: ${listNames}`; - } else { - buttonText = "Add to list"; - } + const [sendRemoveFromListMutation] = useMutation( + gql` + mutation ItemPage_RemoveFromClosetList($listId: ID!, $itemId: ID!) { + removeItemFromClosetList( + listId: $listId + itemId: $itemId + ensureInSomeList: true + ) { + id + hasItem(itemId: $itemId) + } + } + `, + { context: { sendAuth: true } } + ); + + const onChange = React.useCallback( + (e) => { + if (e.target.checked) { + sendAddToListMutation({ + variables: { listId: closetList.id, itemId: item.id }, + optimisticResponse: { + addItemToClosetList: { + __typename: "ClosetList", + id: closetList.id, + hasItem: true, + }, + }, + }).catch((error) => { + console.error(error); + toast({ + status: "error", + title: `Oops, error adding "${item.name}" to "${closetList.name}!"`, + description: + "Check your connection and try again? Sorry about this!", + }); + }); + } else { + sendRemoveFromListMutation({ + variables: { listId: closetList.id, itemId: item.id }, + optimisticResponse: { + removeItemFromClosetList: { + __typename: "ClosetList", + id: closetList.id, + hasItem: false, + }, + }, + }).catch((error) => { + console.error(error); + toast({ + status: "error", + title: `Oops, error removing "${item.name}" from "${closetList.name}!"`, + description: + "Check your connection and try again? Sorry about this!", + }); + }); + } + }, + [closetList, item, sendAddToListMutation, sendRemoveFromListMutation, toast] + ); return ( - - toast({ - status: "warning", - title: "Feature coming soon!", - description: - "I haven't built the cute pop-up for editing your lists yet! Neat concept though, right? 😅 —Matchu", - }) - } - // Even when the button isn't visible, we still render it for layout - // purposes, but hidden and disabled. - opacity={isVisible ? 1 : 0} - aria-hidden={!isVisible} - disabled={!isVisible} + value={closetList.id} + isChecked={closetList.hasItem} + onChange={onChange} > - {/* Flex tricks to center the text, ignoring the caret */} - - - {buttonText} - - - - - + {closetList.name} + ); } diff --git a/src/server/types/ClosetList.js b/src/server/types/ClosetList.js index 7934ad3..300d1e3 100644 --- a/src/server/types/ClosetList.js +++ b/src/server/types/ClosetList.js @@ -23,17 +23,23 @@ const typeDefs = gql` "Whether this is a list of items they own, or items they want." ownsOrWantsItems: OwnsOrWants! - # Each user has a "default list" of items they own/want. When users click - # the Own/Want button on the item page, items go here automatically. (On - # the backend, this is managed as the hangers having a null list ID.) - # - # This field is true if the list is the default list, so we can style it - # differently and change certain behaviors (e.g. can't be deleted). + """ + Each user has a "default list" of items they own/want. When users click + the Own/Want button on the item page, items go here automatically. (On + the backend, this is managed as the hangers having a null list ID.) + + This field is true if the list is the default list, so we can style it + differently and change certain behaviors (e.g. can't be deleted). + """ isDefaultList: Boolean! + "The items in this list." items: [Item!]! - # The user that created this list. + "Whether the given item appears in this list." + hasItem(itemId: ID!): Boolean! + + "The user that created this list." creator: User! } @@ -130,6 +136,28 @@ const resolvers = { return items.map(({ id }) => ({ id })); }, + hasItem: async ( + { isDefaultList, id, userId, ownsOrWantsItems, items: precomputedItems }, + { itemId }, + { closetHangersForListLoader, closetHangersForDefaultListLoader } + ) => { + // TODO: It'd be nice for User.closetLists to *not* just preload all + // items if we're just trying to look up one specific one… but, + // well, here we are! Look through precomputed items first! + if (precomputedItems) { + return precomputedItems.map((item) => item.id).includes(itemId); + } + + const closetHangers = isDefaultList + ? await closetHangersForDefaultListLoader.load({ + userId, + ownsOrWantsItems, + }) + : await closetHangersForListLoader.load(id); + const itemIds = closetHangers.map((h) => h.itemId); + return itemIds.includes(itemId); + }, + creator: async ({ id, isDefaultList, userId }, _, { closetListLoader }) => { if (isDefaultList) { return { id: userId }; diff --git a/src/server/types/Item.js b/src/server/types/Item.js index b04abc3..65cbe3a 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -35,6 +35,11 @@ const typeDefs = gql` currentUserOwnsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE) currentUserWantsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE) + + """ + Which lists the current user has this item in. + Deprecated: We're using ClosetList.hasItem in the client now! + """ currentUserHasInLists: [ClosetList!]! @cacheControl(maxAge: 0, scope: PRIVATE) """ @@ -267,7 +272,27 @@ const typeDefs = gql` addToItemsCurrentUserWants(itemId: ID!): Item removeFromItemsCurrentUserWants(itemId: ID!): Item - removeItemFromClosetList(listId: ID!, itemId: ID!): ClosetList + """ + Add the given item to the given list, if this user has permission. + + If "removeFromDefaultList" is true, this will *also* remove the item from + the corresponding *default* list, if it's present. (This is helpful for UI + interactions like on the item page, where maybe the user clicks "I own + this", *then* adds it to a specific list, and probably didn't mean to + create two copies!) + """ + addItemToClosetList(listId: ID!, itemId: ID!, removeFromDefaultList: Boolean): ClosetList + + """ + Remove the given item from the given list, if this user has permission. + + If "ensureInSomeList" is true, this will *also* add the item to the + corresponding *default* list, if it's not in any others. (This is helpful + for UI interactions like on the item page, where unticking the checkbox for + all the lists doesn't necessarily mean you want to stop owning/wanting the + item altogether!) + """ + removeItemFromClosetList(listId: ID!, itemId: ID!, ensureInSomeList: Boolean): ClosetList } `; @@ -981,9 +1006,9 @@ const resolvers = { return { id: itemId }; }, - removeItemFromClosetList: async ( + addItemToClosetList: async ( _, - { listId, itemId }, + { listId, itemId, removeFromDefaultList }, { currentUserId, db, closetListLoader } ) => { const closetListRef = await loadClosetListOrDefaultList( @@ -994,6 +1019,67 @@ const resolvers = { throw new Error(`list ${listId} not found`); } + const { userId, ownsOrWantsItems } = closetListRef; + + if (userId !== currentUserId) { + throw new Error(`current user does not own this list`); + } + + const now = new Date(); + + await db.beginTransaction(); + try { + if (removeFromDefaultList) { + // First, remove from the default list, if requested. + await db.query( + ` + DELETE FROM closet_hangers + WHERE item_id = ? AND user_id = ? AND list_id IS NULL + AND owned = ? + LIMIT 1; + `, + [itemId, userId, ownsOrWantsItems === "OWNS"] + ); + } + + // Then, add to the new list. + await db.query( + ` + INSERT INTO closet_hangers + (item_id, user_id, owned, list_id, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?); + `, + [itemId, userId, ownsOrWantsItems === "OWNS", listId, 1, now, now] + ); + + await db.commit(); + } catch (error) { + try { + await db.rollback(); + } catch (error2) { + console.warn(`Error rolling back transaction`, error2); + } + + throw error; + } + + return closetListRef; + }, + removeItemFromClosetList: async ( + _, + { listId, itemId, ensureInSomeList }, + { currentUserId, db, closetListLoader } + ) => { + const closetListRef = await loadClosetListOrDefaultList( + listId, + closetListLoader + ); + if (closetListRef == null) { + throw new Error(`list ${listId} not found`); + } + + const { userId, ownsOrWantsItems } = closetListRef; + if (closetListRef.userId !== currentUserId) { throw new Error(`current user does not own this list`); } @@ -1002,16 +1088,52 @@ const resolvers = { ? `(user_id = ? AND owned = ? AND list_id IS NULL)` : `(list_id = ?)`; const listMatcherValues = closetListRef.isDefaultList - ? [closetListRef.userId, closetListRef.ownsOrWantsItems === "OWNS"] - : [closetListRef.id]; + ? [userId, ownsOrWantsItems === "OWNS"] + : [listId]; - await db.query( - ` - DELETE FROM closet_hangers - WHERE ${listMatcherCondition} AND item_id = ? LIMIT 1; - `, - [...listMatcherValues, itemId] - ); + await db.beginTransaction(); + + try { + await db.query( + ` + DELETE FROM closet_hangers + WHERE ${listMatcherCondition} AND item_id = ? LIMIT 1; + `, + [...listMatcherValues, itemId] + ); + + if (ensureInSomeList) { + // If requested, we check whether the item is still in *some* list of + // the same own/want type. If not, we add it to the default list. + const [rows] = await db.query( + ` + SELECT COUNT(*) AS count FROM closet_hangers + WHERE user_id = ? AND item_id = ? AND owned = ? + `, + [userId, itemId, ownsOrWantsItems === "OWNS"] + ); + + if (rows[0].count === 0) { + const now = new Date(); + await db.query( + ` + INSERT INTO closet_hangers + (item_id, user_id, owned, list_id, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?); + `, + [itemId, userId, ownsOrWantsItems === "OWNS", null, 1, now, now] + ); + } + } + + await db.commit(); + } catch (error) { + try { + await db.rollback(); + } catch (error) { + console.warn(`Error rolling back transaction`, error); + } + } return closetListRef; },