From 54abd1ac801de5c11c80249d6dc108799ea602e0 Mon Sep 17 00:00:00 2001 From: Matchu Date: Tue, 24 Nov 2020 14:24:34 -0800 Subject: [PATCH] real trade data on the page! --- src/app/ItemTradesPage.js | 168 +++++++++++++++++++++++++++------ src/server/index.js | 1 + src/server/loaders.js | 88 ++++++++++++++++- src/server/types/ClosetList.js | 96 +++++++++++++++++++ src/server/types/Item.js | 46 +++++++++ src/server/types/User.js | 41 +------- 6 files changed, 374 insertions(+), 66 deletions(-) create mode 100644 src/server/types/ClosetList.js diff --git a/src/app/ItemTradesPage.js b/src/app/ItemTradesPage.js index cf97d05..b491877 100644 --- a/src/app/ItemTradesPage.js +++ b/src/app/ItemTradesPage.js @@ -1,6 +1,12 @@ import React from "react"; import { css } from "emotion"; -import { Box, Tooltip, useColorModeValue, useToken } from "@chakra-ui/core"; +import { + Box, + Skeleton, + Tooltip, + useColorModeValue, + useToken, +} from "@chakra-ui/core"; import gql from "graphql-tag"; import { useQuery } from "@apollo/client"; import { useHistory, useParams } from "react-router-dom"; @@ -14,6 +20,25 @@ export function ItemTradesOfferingPage() { title="Trades: Offering" userHeading="Owner" compareListHeading="They're seeking" + tradesQuery={gql` + query ItemTradesTableOffering($itemId: ID!) { + item(id: $itemId) { + id + trades: tradesOffering { + id + user { + id + username + # lastUpdatedAnyTrade + } + closetList { + id + name + } + } + } + } + `} /> ); } @@ -24,14 +49,38 @@ export function ItemTradesSeekingPage() { title="Trades: Seeking" userHeading="Seeker" compareListHeading="They're offering" + tradesQuery={gql` + query ItemTradesTableSeeking($itemId: ID!) { + item(id: $itemId) { + id + trades: tradesSeeking { + id + user { + id + username + # lastUpdatedAnyTrade + } + closetList { + id + name + } + } + } + } + `} /> ); } -function ItemTradesPage({ title, userHeading, compareListHeading }) { +function ItemTradesPage({ + title, + userHeading, + compareListHeading, + tradesQuery, +}) { const { itemId } = useParams(); - const { loading, error, data } = useQuery( + const { error, data } = useQuery( gql` query ItemTradesPage($itemId: ID!) { item(id: $itemId) { @@ -48,7 +97,7 @@ function ItemTradesPage({ title, userHeading, compareListHeading }) { { variables: { itemId }, returnPartialData: true } ); - usePageTitle(`${data?.item?.name} | ${title}`, { skip: loading }); + usePageTitle(`${data?.item?.name} | ${title}`, { skip: !data?.item?.name }); if (error) { return {error.message}; @@ -63,12 +112,26 @@ function ItemTradesPage({ title, userHeading, compareListHeading }) { itemId={itemId} userHeading={userHeading} compareListHeading={compareListHeading} + tradesQuery={tradesQuery} /> ); } -function ItemTradesTable({ itemId, userHeading, compareListHeading }) { +function ItemTradesTable({ + itemId, + userHeading, + compareListHeading, + tradesQuery, +}) { + const { loading, error, data } = useQuery(tradesQuery, { + variables: { itemId }, + }); + + if (error) { + return {error.message}; + } + return ( - + - {userHeading} - List - + + List + + + {userHeading} + + {/* A small wording tweak to fit better on the xsmall screens! */} - Last updated + Last active Updated - Compare + + Compare + - - - - - + {loading && ( + <> + + + + + + + )} + {!loading && + data.item.trades.length > 0 && + data.item.trades.map((trade) => ( + + ))} + {!loading && data.item.trades.length === 0 && ( + + + No trades yet! + + + )} ); } -function ItemTradesTableRow({ compareListHeading }) { - const href = "/user/6/items#list-1"; - +function ItemTradesTableRow({ compareListHeading, href, username, listName }) { const history = useHistory(); const onClick = React.useCallback(() => history.push(href), [history, href]); const focusBackground = useColorModeValue("gray.100", "gray.600"); @@ -113,16 +207,15 @@ function ItemTradesTableRow({ compareListHeading }) { return ( - Matchu - + - Top priorities and such so yeah + {listName} - + + {username} + + <1 week This week - + + + Placeholder + + + Placeholder + + + Placeholder + + + Placeholder + + + ); +} + function ItemTradesTableCell({ children, as = "td", ...props }) { const borderColor = useColorModeValue("gray.300", "gray.400"); const borderColorCss = useToken("colors", borderColor); @@ -188,7 +303,6 @@ function ItemTradesTableCell({ children, as = "td", ...props }) { paddingX="4" paddingY="2" textAlign="left" - fontSize={{ base: "xs", sm: "sm" }} className={css` /* Lol sigh, getting this right is way more involved than I wish it * were. What I really want is border-collapse and a simple 1px border, diff --git a/src/server/index.js b/src/server/index.js index 7447a7a..1b53c5e 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -33,6 +33,7 @@ const schema = makeExecutableSchema( mergeTypeDefsAndResolvers([ { typeDefs: rootTypeDefs, resolvers: {} }, require("./types/AppearanceLayer"), + require("./types/ClosetList"), require("./types/Item"), require("./types/MutationsForSupport"), require("./types/Outfit"), diff --git a/src/server/loaders.js b/src/server/loaders.js index a0e9ad3..e2108af 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -1,6 +1,19 @@ const DataLoader = require("dataloader"); const { normalizeRow } = require("./util"); +const buildClosetListLoader = (db) => + new DataLoader(async (ids) => { + const qs = ids.map((_) => "?").join(","); + const [rows, _] = await db.execute( + `SELECT * FROM closet_lists WHERE id IN (${qs})`, + ids + ); + + const entities = rows.map(normalizeRow); + + return ids.map((id) => entities.find((e) => e.id === id)); + }); + const buildColorLoader = (db) => { const colorLoader = new DataLoader(async (colorIds) => { const qs = colorIds.map((_) => "?").join(","); @@ -442,6 +455,71 @@ const buildItemTradeCountsLoader = (db) => { cacheKeyFn: ({ itemId, isOwned }) => `${itemId}-${isOwned}` } ); +const buildItemTradesLoader = (db, loaders) => + new DataLoader( + async (itemIdOwnedPairs) => { + const qs = itemIdOwnedPairs + .map((_) => "(closet_hangers.item_id = ? AND closet_hangers.owned = ?)") + .join(" OR "); + const values = itemIdOwnedPairs + .map(({ itemId, isOwned }) => [itemId, isOwned]) + .flat(); + const [rows, _] = await db.execute( + { + sql: ` + SELECT + closet_hangers.*, closet_lists.*, users.* + FROM closet_hangers + INNER JOIN users ON users.id = closet_hangers.user_id + LEFT JOIN closet_lists ON closet_lists.id = closet_hangers.list_id + WHERE ( + (${qs}) + AND ( + (closet_hangers.list_id IS NOT NULL AND closet_lists.visibility >= 2) + OR ( + closet_hangers.list_id IS NULL AND closet_hangers.owned = 1 + AND users.owned_closet_hangers_visibility >= 2 + ) + OR ( + closet_hangers.list_id IS NULL AND closet_hangers.owned = 0 + AND users.wanted_closet_hangers_visibility >= 2 + ) + ) + ); + `, + nestTables: true, + }, + values + ); + + const entities = rows.map((row) => ({ + closetHanger: normalizeRow(row.closet_hangers), + closetList: normalizeRow(row.closet_lists), + user: normalizeRow(row.users), + })); + + for (const entity of entities) { + loaders.userLoader.prime(entity.user.id, entity.user); + loaders.closetListLoader.prime(entity.closetList.id, entity.closetList); + } + + return itemIdOwnedPairs.map(({ itemId, isOwned }) => + entities + .filter( + (e) => + e.closetHanger.itemId === itemId && + Boolean(e.closetHanger.owned) === isOwned + ) + .map((e) => ({ + id: e.closetHanger.id, + closetList: e.closetList.id ? e.closetList : null, + user: e.user, + })) + ); + }, + { cacheKeyFn: ({ itemId, isOwned }) => `${itemId}-${isOwned}` } + ); + const buildPetTypeLoader = (db, loaders) => new DataLoader(async (petTypeIds) => { const qs = petTypeIds.map((_) => "?").join(","); @@ -821,7 +899,7 @@ const buildUserClosetHangersLoader = (db) => ); }); -const buildUserClosetListsLoader = (db) => +const buildUserClosetListsLoader = (db, loaders) => new DataLoader(async (userIds) => { const qs = userIds.map((_) => "?").join(","); const [rows, _] = await db.execute( @@ -830,7 +908,11 @@ const buildUserClosetListsLoader = (db) => ORDER BY name`, userIds ); + const entities = rows.map(normalizeRow); + for (const entity of entities) { + loaders.closetListLoader.prime(entity.id, entity); + } return userIds.map((userId) => entities.filter((e) => e.userId === String(userId)) @@ -891,6 +973,7 @@ function buildLoaders(db) { const loaders = {}; loaders.loadAllPetTypes = loadAllPetTypes(db); + loaders.closetListLoader = buildClosetListLoader(db); loaders.colorLoader = buildColorLoader(db); loaders.colorTranslationLoader = buildColorTranslationLoader(db); loaders.itemLoader = buildItemLoader(db); @@ -904,6 +987,7 @@ function buildLoaders(db) { ); loaders.itemAllOccupiedZonesLoader = buildItemAllOccupiedZonesLoader(db); loaders.itemTradeCountsLoader = buildItemTradeCountsLoader(db); + loaders.itemTradesLoader = buildItemTradesLoader(db, loaders); loaders.petTypeLoader = buildPetTypeLoader(db, loaders); loaders.petTypeBySpeciesAndColorLoader = buildPetTypeBySpeciesAndColorLoader( db, @@ -937,7 +1021,7 @@ function buildLoaders(db) { loaders.userByNameLoader = buildUserByNameLoader(db); loaders.userByEmailLoader = buildUserByEmailLoader(db); loaders.userClosetHangersLoader = buildUserClosetHangersLoader(db); - loaders.userClosetListsLoader = buildUserClosetListsLoader(db); + loaders.userClosetListsLoader = buildUserClosetListsLoader(db, loaders); loaders.zoneLoader = buildZoneLoader(db); loaders.zoneTranslationLoader = buildZoneTranslationLoader(db); diff --git a/src/server/types/ClosetList.js b/src/server/types/ClosetList.js new file mode 100644 index 0000000..6885368 --- /dev/null +++ b/src/server/types/ClosetList.js @@ -0,0 +1,96 @@ +const { gql } = require("apollo-server"); + +const typeDefs = gql` + enum OwnsOrWants { + OWNS + WANTS + } + + type ClosetList { + id: ID! + name: String + + # A user-customized description. May contain Markdown and limited HTML. + description: String + + # 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). + isDefaultList: Boolean! + + items: [Item!]! + } +`; + +const resolvers = { + ClosetList: { + id: ({ id, isDefaultList, userId, ownsOrWantsItems }) => { + if (isDefaultList) { + return `user-${userId}-default-list-${ownsOrWantsItems}`; + } + + return id; + }, + + name: async ({ id, isDefaultList }, _, { closetListLoader }) => { + if (isDefaultList) { + return "Not in a list"; + } + + const list = await closetListLoader.load(id); + return list.name; + }, + + description: async ({ id, isDefaultList }, _, { closetListLoader }) => { + if (isDefaultList) { + return null; + } + + const list = await closetListLoader.load(id); + return list.description; + }, + + ownsOrWantsItems: async ( + { id, isDefaultList, ownsOrWantsItems }, + _, + { closetListLoader } + ) => { + if (isDefaultList) { + return ownsOrWantsItems; + } + + const list = await closetListLoader.load(id); + return list.hangersOwned ? "OWNS" : "WANTS"; + }, + + isDefaultList: ({ isDefaultList }) => { + return Boolean(isDefaultList); + }, + + items: ({ items }) => { + // HACK: When called from User.js, for fetching all of a user's lists at + // once, this is provided in the returned object. This was before + // we separated out the ClosetList resolvers at all! But I'm not + // bothering to port it, because it would mean writing a new + // loader, and we don't yet have any endpoints that actually need + // this. + if (items) { + return items; + } + + throw new Error( + `TODO: Not implemented, we still duplicate / bulk-implement some of ` + + `the list resolver stuff in User.js. Break that out into real ` + + `ClosetList loaders and resolvers!` + ); + }, + }, +}; + +module.exports = { typeDefs, resolvers }; diff --git a/src/server/types/Item.js b/src/server/types/Item.js index 7dca354..f049676 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -26,6 +26,10 @@ const typeDefs = gql` numUsersOfferingThis: Int! numUsersSeekingThis: Int! + # The trades available for this item, grouped by offering vs seeking. + tradesOffering: [ItemTrade!]! + tradesSeeking: [ItemTrade!]! + # How this item appears on the given species/color combo. If it does not # fit the pet, we'll return an empty ItemAppearance with no layers. appearanceOn(speciesId: ID!, colorId: ID!): ItemAppearance! @@ -79,12 +83,21 @@ const typeDefs = gql` PB } + # TODO: I guess I didn't add the NC/NP/PB filter to this. Does that cause + # bugs in comparing results on the client? (Also, should we just throw + # this out for a better merge function?) type ItemSearchResult { query: String! zones: [Zone!]! items: [Item!]! } + type ItemTrade { + id: ID! + user: User! + closetList: ClosetList! + } + extend type Query { item(id: ID!): Item items(ids: [ID!]!): [Item!]! @@ -204,6 +217,39 @@ const resolvers = { return count; }, + tradesOffering: async ({ id }, _, { itemTradesLoader }) => { + const trades = await itemTradesLoader.load({ itemId: id, isOwned: true }); + return trades.map((trade) => ({ + id: trade.id, + closetList: trade.closetList + ? { id: trade.closetList.id } + : { + isDefaultList: true, + userId: trade.user.id, + ownsOrWantsItems: "OWNS", + }, + user: { id: trade.user.id }, + })); + }, + + tradesSeeking: async ({ id }, _, { itemTradesLoader }) => { + const trades = await itemTradesLoader.load({ + itemId: id, + isOwned: false, + }); + return trades.map((trade) => ({ + id: trade.id, + closetList: trade.closetList + ? { id: trade.closetList.id } + : { + isDefaultList: true, + userId: trade.user.id, + ownsOrWantsItems: "WANTS", + }, + user: { id: trade.user.id }, + })); + }, + appearanceOn: async ( { id }, { speciesId, colorId }, diff --git a/src/server/types/User.js b/src/server/types/User.js index 2f02387..14af26c 100644 --- a/src/server/types/User.js +++ b/src/server/types/User.js @@ -1,11 +1,6 @@ const { gql } = require("apollo-server"); const typeDefs = gql` - enum OwnsOrWants { - OWNS - WANTS - } - type User { id: ID! username: String! @@ -17,27 +12,6 @@ const typeDefs = gql` itemsTheyWant: [Item!]! } - type ClosetList { - id: ID! - name: String - - # A user-customized description. May contain Markdown and limited HTML. - description: String - - # 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). - isDefaultList: Boolean! - - items: [Item!]! - } - extend type Query { user(id: ID!): User userByName(name: String!): User @@ -162,10 +136,6 @@ const resolvers = { .filter((closetList) => isCurrentUser || closetList.visibility >= 1) .map((closetList) => ({ id: closetList.id, - name: closetList.name, - description: closetList.description, - ownsOrWantsItems: closetList.hangersOwned ? "OWNS" : "WANTS", - isDefaultList: false, items: allClosetHangers .filter((h) => h.listId === closetList.id) .map((h) => ({ id: h.itemId })), @@ -173,11 +143,9 @@ const resolvers = { if (isCurrentUser || user.ownedClosetHangersVisibility >= 1) { closetListNodes.push({ - id: `user-${id}-default-list-OWNS`, - name: "Not in a list", - description: null, - ownsOrWantsItems: "OWNS", isDefaultList: true, + userId: id, + ownsOrWantsItems: "OWNS", items: allClosetHangers .filter((h) => h.listId == null && h.owned) .map((h) => ({ id: h.itemId })), @@ -186,9 +154,8 @@ const resolvers = { if (isCurrentUser || user.wantedClosetHangersVisibility >= 1) { closetListNodes.push({ - id: `user-${id}-default-list-WANTS`, - name: "Not in a list", - description: null, + isDefaultList: true, + userId: id, ownsOrWantsItems: "WANTS", isDefaultList: true, items: allClosetHangers