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; },