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, Flex,
usePrefersReducedMotion, usePrefersReducedMotion,
Grid, Grid,
Popover,
PopoverContent,
PopoverTrigger,
Checkbox,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
CheckIcon, CheckIcon,
@ -146,13 +150,17 @@ function ItemPageOwnWantButtons({ itemId }) {
query ItemPageOwnWantButtons($itemId: ID!) { query ItemPageOwnWantButtons($itemId: ID!) {
item(id: $itemId) { item(id: $itemId) {
id id
name
currentUserOwnsThis currentUserOwnsThis
currentUserWantsThis currentUserWantsThis
currentUserHasInLists { }
currentUser {
closetLists {
id id
name name
isDefaultList isDefaultList
ownsOrWantsItems ownsOrWantsItems
hasItem(itemId: $itemId)
} }
} }
} }
@ -164,11 +172,10 @@ function ItemPageOwnWantButtons({ itemId }) {
return <Box color="red.400">{error.message}</Box>; return <Box color="red.400">{error.message}</Box>;
} }
const closetLists = data?.item?.currentUserHasInLists || []; const closetLists = data?.currentUser?.closetLists || [];
const ownedLists = closetLists.filter((cl) => cl.ownsOrWantsItems === "OWNS"); const realLists = closetLists.filter((cl) => !cl.isDefaultList);
const wantedLists = closetLists.filter( const ownedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "OWNS");
(cl) => cl.ownsOrWantsItems === "WANTS" const wantedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "WANTS");
);
return ( return (
<Grid <Grid
@ -185,9 +192,13 @@ function ItemPageOwnWantButtons({ itemId }) {
isChecked={data?.item?.currentUserOwnsThis} isChecked={data?.item?.currentUserOwnsThis}
/> />
</SubtleSkeleton> </SubtleSkeleton>
<ItemPageOwnWantListsButton <ItemPageOwnWantListsDropdown
isVisible={data?.item?.currentUserOwnsThis}
closetLists={ownedLists} 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}> <SubtleSkeleton isLoaded={!loading}>
@ -196,65 +207,195 @@ function ItemPageOwnWantButtons({ itemId }) {
isChecked={data?.item?.currentUserWantsThis} isChecked={data?.item?.currentUserWantsThis}
/> />
</SubtleSkeleton> </SubtleSkeleton>
<ItemPageOwnWantListsButton <ItemPageOwnWantListsDropdown
isVisible={data?.item?.currentUserWantsThis}
closetLists={wantedLists} 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> </Grid>
); );
} }
function ItemPageOwnWantListsButton({ closetLists, isVisible }) { 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 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
ref={ref}
as="button"
fontSize="xs"
alignItems="center"
borderRadius="sm"
width="100%"
_hover={{ textDecoration: "underline" }}
_focus={{
textDecoration: "underline",
outline: "0",
boxShadow: "outline",
}}
// 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" />
<Box textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{buttonText}
</Box>
<Flex flex="1 0 0">
<ChevronDownIcon marginLeft="1" />
</Flex>
</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 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; const [sendRemoveFromListMutation] = useMutation(
if (realLists.length === 1) { gql`
buttonText = `In list: "${realLists[0].name}"`; mutation ItemPage_RemoveFromClosetList($listId: ID!, $itemId: ID!) {
} else if (realLists.length > 1) { removeItemFromClosetList(
const listNames = realLists.map((cl) => `"${cl.name}"`).join(", "); listId: $listId
buttonText = `${realLists.length} lists: ${listNames}`; itemId: $itemId
} else { ensureInSomeList: true
buttonText = "Add to list"; ) {
} 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 ( return (
<Flex <Checkbox
as="button" size="sm"
fontSize="xs"
alignItems="center"
borderRadius="sm"
width="100%" width="100%"
_hover={{ textDecoration: "underline" }} value={closetList.id}
_focus={{ isChecked={closetList.hasItem}
textDecoration: "underline", onChange={onChange}
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}
> >
{/* Flex tricks to center the text, ignoring the caret */} {closetList.name}
<Box flex="1 0 0" /> </Checkbox>
<Box textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
{buttonText}
</Box>
<Flex flex="1 0 0">
<ChevronDownIcon marginLeft="1" />
</Flex>
</Flex>
); );
} }

View file

@ -23,17 +23,23 @@ const typeDefs = gql`
"Whether this is a list of items they own, or items they want." "Whether this is a list of items they own, or items they want."
ownsOrWantsItems: OwnsOrWants! 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 Each user has a "default list" of items they own/want. When users click
# the backend, this is managed as the hangers having a null list ID.) 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). 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! isDefaultList: Boolean!
"The items in this list."
items: [Item!]! 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! creator: User!
} }
@ -130,6 +136,28 @@ const resolvers = {
return items.map(({ id }) => ({ id })); 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 }) => { creator: async ({ id, isDefaultList, userId }, _, { closetListLoader }) => {
if (isDefaultList) { if (isDefaultList) {
return { id: userId }; return { id: userId };

View file

@ -35,6 +35,11 @@ const typeDefs = gql`
currentUserOwnsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE) currentUserOwnsThis: Boolean! @cacheControl(maxAge: 0, scope: PRIVATE)
currentUserWantsThis: 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) currentUserHasInLists: [ClosetList!]! @cacheControl(maxAge: 0, scope: PRIVATE)
""" """
@ -267,7 +272,27 @@ const typeDefs = gql`
addToItemsCurrentUserWants(itemId: ID!): Item addToItemsCurrentUserWants(itemId: ID!): Item
removeFromItemsCurrentUserWants(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 }; return { id: itemId };
}, },
removeItemFromClosetList: async ( addItemToClosetList: async (
_, _,
{ listId, itemId }, { listId, itemId, removeFromDefaultList },
{ currentUserId, db, closetListLoader } { currentUserId, db, closetListLoader }
) => { ) => {
const closetListRef = await loadClosetListOrDefaultList( const closetListRef = await loadClosetListOrDefaultList(
@ -994,6 +1019,67 @@ const resolvers = {
throw new Error(`list ${listId} not found`); 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) { if (closetListRef.userId !== currentUserId) {
throw new Error(`current user does not own this list`); 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)` ? `(user_id = ? AND owned = ? AND list_id IS NULL)`
: `(list_id = ?)`; : `(list_id = ?)`;
const listMatcherValues = closetListRef.isDefaultList const listMatcherValues = closetListRef.isDefaultList
? [closetListRef.userId, closetListRef.ownsOrWantsItems === "OWNS"] ? [userId, ownsOrWantsItems === "OWNS"]
: [closetListRef.id]; : [listId];
await db.query( await db.beginTransaction();
`
DELETE FROM closet_hangers try {
WHERE ${listMatcherCondition} AND item_id = ? LIMIT 1; await db.query(
`, `
[...listMatcherValues, itemId] 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; return closetListRef;
}, },