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.)
This commit is contained in:
parent
e6a94eaf80
commit
e95f6abbe4
3 changed files with 364 additions and 73 deletions
|
@ -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 <Box color="red.400">{error.message}</Box>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Grid
|
||||
|
@ -185,9 +192,13 @@ function ItemPageOwnWantButtons({ itemId }) {
|
|||
isChecked={data?.item?.currentUserOwnsThis}
|
||||
/>
|
||||
</SubtleSkeleton>
|
||||
<ItemPageOwnWantListsButton
|
||||
isVisible={data?.item?.currentUserOwnsThis}
|
||||
<ItemPageOwnWantListsDropdown
|
||||
closetLists={ownedLists}
|
||||
item={data?.item}
|
||||
// Show the dropdown if the user owns this, and has at least one custom
|
||||
// list it could belong to.
|
||||
isVisible={data?.item?.currentUserOwnsThis && ownedLists.length >= 1}
|
||||
popoverPlacement="bottom-end"
|
||||
/>
|
||||
|
||||
<SubtleSkeleton isLoaded={!loading}>
|
||||
|
@ -196,31 +207,59 @@ function ItemPageOwnWantButtons({ itemId }) {
|
|||
isChecked={data?.item?.currentUserWantsThis}
|
||||
/>
|
||||
</SubtleSkeleton>
|
||||
<ItemPageOwnWantListsButton
|
||||
isVisible={data?.item?.currentUserWantsThis}
|
||||
<ItemPageOwnWantListsDropdown
|
||||
closetLists={wantedLists}
|
||||
item={data?.item}
|
||||
// Show the dropdown if the user wants this, and has at least one
|
||||
// custom list it could belong to.
|
||||
isVisible={data?.item?.currentUserWantsThis && wantedLists.length >= 1}
|
||||
popoverPlacement="bottom-start"
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageOwnWantListsButton({ closetLists, isVisible }) {
|
||||
const toast = useToast();
|
||||
function ItemPageOwnWantListsDropdown({
|
||||
closetLists,
|
||||
item,
|
||||
isVisible,
|
||||
popoverPlacement,
|
||||
}) {
|
||||
return (
|
||||
<Popover placement={popoverPlacement}>
|
||||
<PopoverTrigger>
|
||||
<ItemPageOwnWantListsDropdownButton
|
||||
closetLists={closetLists}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent padding="2" width="64">
|
||||
<ItemPageOwnWantListsDropdownContent
|
||||
closetLists={closetLists}
|
||||
item={item}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const realLists = closetLists.filter((cl) => !cl.isDefaultList);
|
||||
const ItemPageOwnWantListsDropdownButton = React.forwardRef(
|
||||
({ closetLists, isVisible, ...props }, ref) => {
|
||||
const listsToShow = closetLists.filter((cl) => cl.hasItem);
|
||||
|
||||
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}`;
|
||||
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
|
||||
ref={ref}
|
||||
as="button"
|
||||
fontSize="xs"
|
||||
alignItems="center"
|
||||
|
@ -232,19 +271,12 @@ function ItemPageOwnWantListsButton({ closetLists, isVisible }) {
|
|||
outline: "0",
|
||||
boxShadow: "outline",
|
||||
}}
|
||||
onClick={() =>
|
||||
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}
|
||||
{...props}
|
||||
>
|
||||
{/* Flex tricks to center the text, ignoring the caret */}
|
||||
<Box flex="1 0 0" />
|
||||
|
@ -257,6 +289,115 @@ function ItemPageOwnWantListsButton({ closetLists, isVisible }) {
|
|||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function ItemPageOwnWantListsDropdownContent({ closetLists, item }) {
|
||||
return (
|
||||
<Box as="ul" listStyleType="none">
|
||||
{closetLists.map((closetList) => (
|
||||
<Box key={closetList.id} as="li">
|
||||
<ItemPageOwnWantsListsDropdownRow
|
||||
closetList={closetList}
|
||||
item={item}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageOwnWantsListsDropdownRow({ closetList, item }) {
|
||||
const toast = useToast();
|
||||
|
||||
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 } }
|
||||
);
|
||||
|
||||
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 (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
width="100%"
|
||||
value={closetList.id}
|
||||
isChecked={closetList.hasItem}
|
||||
onChange={onChange}
|
||||
>
|
||||
{closetList.name}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPageOwnButton({ itemId, isChecked }) {
|
||||
const theme = useTheme();
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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,9 +1088,12 @@ 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.beginTransaction();
|
||||
|
||||
try {
|
||||
await db.query(
|
||||
`
|
||||
DELETE FROM closet_hangers
|
||||
|
@ -1013,6 +1102,39 @@ const resolvers = {
|
|||
[...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;
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue