Wire up the Remove button for item lists

Did some stuff in here for parsing the default list ID too. We skipped that when making the new list index page, but now maybe you could reasonably link to the default list? 🤔 not sure it's a huge deal though
This commit is contained in:
Emi Matchu 2021-09-30 19:26:09 -07:00
parent b6b99d899f
commit 6efd542f49
6 changed files with 188 additions and 14 deletions

View file

@ -407,7 +407,8 @@ export function ClosetListContents({
<Box> <Box>
{itemsToShow.length > 0 ? ( {itemsToShow.length > 0 ? (
<ClosetItemList <ClosetItemList
items={itemsToShow} itemsToShow={itemsToShow}
closetList={closetList}
canEdit={isCurrentUser} canEdit={isCurrentUser}
tradeMatchingMode={tradeMatchingMode} tradeMatchingMode={tradeMatchingMode}
/> />
@ -447,11 +448,17 @@ export function ClosetListContents({
const ITEM_CARD_WIDTH = 112 + 16; const ITEM_CARD_WIDTH = 112 + 16;
const ITEM_CARD_HEIGHT = 171 + 16; const ITEM_CARD_HEIGHT = 171 + 16;
function ClosetItemList({ items, canEdit, tradeMatchingMode }) { function ClosetItemList({
itemsToShow,
closetList,
canEdit,
tradeMatchingMode,
}) {
const renderItem = (item) => ( const renderItem = (item) => (
<ClosetListItemCard <ClosetListItemCard
key={item.id} key={item.id}
item={item} item={item}
closetList={closetList}
canEdit={canEdit} canEdit={canEdit}
tradeMatchingMode={tradeMatchingMode} tradeMatchingMode={tradeMatchingMode}
/> />
@ -459,10 +466,10 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) {
// For small lists, we don't bother to virtualize, because it slows down // For small lists, we don't bother to virtualize, because it slows down
// scrolling! (This helps a lot on the lists index page.) // scrolling! (This helps a lot on the lists index page.)
if (items.length < 30) { if (itemsToShow.length < 30) {
return ( return (
<Wrap spacing="4" justify="center"> <Wrap spacing="4" justify="center">
{items.map((item) => ( {itemsToShow.map((item) => (
<WrapItem key={item.id}>{renderItem(item)}</WrapItem> <WrapItem key={item.id}>{renderItem(item)}</WrapItem>
))} ))}
</Wrap> </Wrap>
@ -475,7 +482,7 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) {
<AutoSizer disableHeight> <AutoSizer disableHeight>
{({ width }) => { {({ width }) => {
const numItemsPerRow = Math.floor(width / ITEM_CARD_WIDTH); const numItemsPerRow = Math.floor(width / ITEM_CARD_WIDTH);
const numRows = Math.ceil(items.length / numItemsPerRow); const numRows = Math.ceil(itemsToShow.length / numItemsPerRow);
return ( return (
<div ref={registerChild}> <div ref={registerChild}>
@ -487,7 +494,7 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) {
rowHeight={ITEM_CARD_HEIGHT} rowHeight={ITEM_CARD_HEIGHT}
rowRenderer={({ index: rowIndex, key, style }) => { rowRenderer={({ index: rowIndex, key, style }) => {
const firstItemIndex = rowIndex * numItemsPerRow; const firstItemIndex = rowIndex * numItemsPerRow;
const itemsForRow = items.slice( const itemsForRow = itemsToShow.slice(
firstItemIndex, firstItemIndex,
firstItemIndex + numItemsPerRow firstItemIndex + numItemsPerRow
); );
@ -522,7 +529,45 @@ function ClosetItemList({ items, canEdit, tradeMatchingMode }) {
); );
} }
function ClosetListItemCard({ item, canEdit, tradeMatchingMode }) { function ClosetListItemCard({ item, closetList, canEdit, tradeMatchingMode }) {
const toast = useToast();
const [sendRemoveMutation] = useMutation(
gql`
mutation ClosetListItemCard_RemoveItem($listId: ID!, $itemId: ID!) {
removeItemFromClosetList(listId: $listId, itemId: $itemId) {
id
items {
id
}
}
}
`,
{ context: { sendAuth: true } }
);
const onRemove = React.useCallback(() => {
sendRemoveMutation({
variables: { itemId: item.id, listId: closetList.id },
optimisticResponse: {
removeItemFromClosetList: {
id: closetList.id,
__typename: "ClosetList",
items: closetList.items
.filter(({ id }) => id !== item.id)
.map(({ id }) => ({ id, __typename: "Item" })),
},
},
}).catch((error) => {
console.error(error);
toast({
status: "error",
title: `Oops, we couldn't remove "${item.name}" from this list!`,
description: "Check your connection and try again. Sorry!",
});
});
}, [sendRemoveMutation, item, closetList, toast]);
return ( return (
<ItemCard <ItemCard
key={item.id} key={item.id}
@ -530,7 +575,7 @@ function ClosetListItemCard({ item, canEdit, tradeMatchingMode }) {
variant="grid" variant="grid"
tradeMatchingMode={tradeMatchingMode} tradeMatchingMode={tradeMatchingMode}
showRemoveButton={canEdit} showRemoveButton={canEdit}
onRemove={() => alert("TODO")} onRemove={onRemove}
/> />
); );
} }

View file

@ -149,6 +149,13 @@ const typePolicies = {
}, },
}, },
}, },
ClosetList: {
fields: {
// When loading the updated contents of a list, replace it entirely.
items: { merge: false },
},
},
}; };
const httpLink = createHttpLink({ uri: "/api/graphql" }); const httpLink = createHttpLink({ uri: "/api/graphql" });

View file

@ -97,15 +97,17 @@ function SquareItemCard({
right: 0; right: 0;
top: 0; top: 0;
transform: translate(50%, -50%); transform: translate(50%, -50%);
z-index: 1;
/* Apply some padding, so accidental clicks around the button /* Apply some padding, so accidental clicks around the button
* don't click the link instead, or vice-versa! */ * don't click the link instead, or vice-versa! */
padding: 0.5em; padding: 0.75em;
opacity: 0; opacity: 0;
[role="group"]:hover &, [role="group"]:hover &,
[role="group"]:focus-within &, [role="group"]:focus-within &,
&:hover, &:hover,
&:focus { &:focus-within {
opacity: 1; opacity: 1;
} }
`} `}

View file

@ -30,6 +30,33 @@ const buildClosetHangersForListLoader = (db) =>
); );
}); });
const buildClosetHangersForDefaultListLoader = (db) =>
new DataLoader(async (userIdAndOwnsOrWantsItemsPairs) => {
const conditions = userIdAndOwnsOrWantsItemsPairs
.map((_) => `(user_id = ? AND owned = ? AND list_id IS NULL)`)
.join(" OR ");
const values = userIdAndOwnsOrWantsItemsPairs
.map(({ userId, ownsOrWantsItems }) => [
userId,
ownsOrWantsItems === "OWNS",
])
.flat();
const [rows] = await db.execute(
`SELECT * FROM closet_hangers WHERE ${conditions}`,
values
);
const entities = rows.map(normalizeRow);
return userIdAndOwnsOrWantsItemsPairs.map(({ userId, ownsOrWantsItems }) =>
entities.filter(
(e) =>
e.userId === userId &&
Boolean(e.owned) === (ownsOrWantsItems === "OWNS")
)
);
});
const buildColorLoader = (db) => { const buildColorLoader = (db) => {
const colorLoader = new DataLoader(async (colorIds) => { const colorLoader = new DataLoader(async (colorIds) => {
const qs = colorIds.map((_) => "?").join(","); const qs = colorIds.map((_) => "?").join(",");
@ -1393,6 +1420,9 @@ function buildLoaders(db) {
loaders.closetListLoader = buildClosetListLoader(db); loaders.closetListLoader = buildClosetListLoader(db);
loaders.closetHangersForListLoader = buildClosetHangersForListLoader(db); loaders.closetHangersForListLoader = buildClosetHangersForListLoader(db);
loaders.closetHangersForDefaultListLoader = buildClosetHangersForDefaultListLoader(
db
);
loaders.colorLoader = buildColorLoader(db); loaders.colorLoader = buildColorLoader(db);
loaders.colorTranslationLoader = buildColorTranslationLoader(db); loaders.colorTranslationLoader = buildColorTranslationLoader(db);
loaders.itemLoader = buildItemLoader(db); loaders.itemLoader = buildItemLoader(db);

View file

@ -103,9 +103,13 @@ const resolvers = {
}, },
items: async ( items: async (
{ id, items: precomputedItems }, { isDefaultList, id, userId, ownsOrWantsItems, items: precomputedItems },
_, _,
{ itemLoader, closetHangersForListLoader } {
itemLoader,
closetHangersForListLoader,
closetHangersForDefaultListLoader,
}
) => { ) => {
// HACK: When called from User.js, for fetching all of a user's lists at // HACK: When called from User.js, for fetching all of a user's lists at
// once, this is provided in the returned object. Just use it! // once, this is provided in the returned object. Just use it!
@ -114,8 +118,12 @@ const resolvers = {
return precomputedItems; return precomputedItems;
} }
// TODO: Support the not-in-a-list case! const closetHangers = isDefaultList
const closetHangers = await closetHangersForListLoader.load(id); ? await closetHangersForDefaultListLoader.load({
userId,
ownsOrWantsItems,
})
: await closetHangersForListLoader.load(id);
const itemIds = closetHangers.map((h) => h.itemId); const itemIds = closetHangers.map((h) => h.itemId);
const items = await itemLoader.loadMany(itemIds); const items = await itemLoader.loadMany(itemIds);

View file

@ -266,6 +266,8 @@ 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
} }
`; `;
@ -952,7 +954,87 @@ const resolvers = {
return { id: itemId }; return { id: itemId };
}, },
removeItemFromClosetList: async (
_,
{ listId, itemId },
{ currentUserId, db, closetListLoader }
) => {
const closetListRef = await loadClosetListOrDefaultList(
listId,
closetListLoader
);
if (closetListRef == null) {
throw new Error(`list ${listId} not found`);
}
if (closetListRef.userId !== currentUserId) {
throw new Error(`current user does not own this list`);
}
const listMatcherCondition = closetListRef.isDefaultList
? `(user_id = ? AND owned = ? AND list_id IS NULL)`
: `(list_id = ?)`;
const listMatcherValues = closetListRef.isDefaultList
? [closetListRef.userId, closetListRef.ownsOrWantsItems === "OWNS"]
: [closetListRef.id];
await db.query(
`
DELETE FROM closet_hangers
WHERE ${listMatcherCondition} AND item_id = ? LIMIT 1;
`,
[...listMatcherValues, itemId]
);
return closetListRef;
},
}, },
}; };
// This matches the ID that we return from ClosetList for the default list.
const DEFAULT_LIST_ID_PATTERN = /user-(.+?)-default-list-(OWNS|WANTS)/;
/**
* Given a list ID returned from ClosetList (which the client might've passed
* back to us in a mutation), parse it as either a *real* list ID, or the
* placeholder ID we provide to "default" lists; and return the fields that
* our ClosetList resolver expects in order to return the correct list. (That
* is: `isDefaultList`, `id` (perhaps null), `userId`, and `ownsOrWantsItems`.
* The resolver doesn't need all of the fields in both cases, but we return
* both in case you want to use them for other things, e.g. checking the
* `userId`!)
*
* (Or return null, if the list ID does not correspond to a default list *or* a
* real list in the database.)
*/
async function loadClosetListOrDefaultList(listId, closetListLoader) {
if (listId == null) {
return null;
}
const defaultListMatch = listId.match(DEFAULT_LIST_ID_PATTERN);
if (defaultListMatch) {
const userId = defaultListMatch[1];
const ownsOrWantsItems = defaultListMatch[2];
return {
isDefaultList: true,
id: null,
userId,
ownsOrWantsItems,
};
}
const closetList = await closetListLoader.load(listId);
if (closetList) {
return {
isDefaultList: false,
id: closetList.id,
userId: closetList.userId,
ownsOrWantsItems: closetList.hangersOwned ? "OWNS" : "WANTS",
};
}
return null;
}
module.exports = { typeDefs, resolvers }; module.exports = { typeDefs, resolvers };