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 (
+
+ {/* Flex tricks to center the text, ignoring the caret */}
+
+
+ {buttonText}
+
+
+
+
+
+ );
+ }
+);
+
+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;
},