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:
Emi Matchu 2021-11-30 16:36:00 -08:00
parent e6a94eaf80
commit e95f6abbe4
3 changed files with 364 additions and 73 deletions

View file

@ -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();

View file

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

View file

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