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:
parent
b6b99d899f
commit
6efd542f49
6 changed files with 188 additions and 14 deletions
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" });
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
Loading…
Reference in a new issue