real trade data on the page!

This commit is contained in:
Emi Matchu 2020-11-24 14:24:34 -08:00
parent 6681f9642a
commit 54abd1ac80
6 changed files with 374 additions and 66 deletions

View file

@ -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 <Box color="red.400">{error.message}</Box>;
@ -63,12 +112,26 @@ function ItemTradesPage({ title, userHeading, compareListHeading }) {
itemId={itemId}
userHeading={userHeading}
compareListHeading={compareListHeading}
tradesQuery={tradesQuery}
/>
</ItemPageLayout>
);
}
function ItemTradesTable({ itemId, userHeading, compareListHeading }) {
function ItemTradesTable({
itemId,
userHeading,
compareListHeading,
tradesQuery,
}) {
const { loading, error, data } = useQuery(tradesQuery, {
variables: { itemId },
});
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
return (
<Box
as="table"
@ -78,34 +141,65 @@ function ItemTradesTable({ itemId, userHeading, compareListHeading }) {
/* Chakra doesn't have props for these! */
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
`}
>
<Box as="thead">
<Box as="thead" fontSize={{ base: "xs", sm: "sm" }}>
<Box as="tr">
<ItemTradesTableCell as="th">{userHeading}</ItemTradesTableCell>
<ItemTradesTableCell as="th">List</ItemTradesTableCell>
<ItemTradesTableCell as="th">
<ItemTradesTableCell as="th" width={{ base: "30%", md: "auto" }}>
List
</ItemTradesTableCell>
<ItemTradesTableCell as="th" width={{ base: "23%", md: "18ex" }}>
{userHeading}
</ItemTradesTableCell>
<ItemTradesTableCell as="th" width={{ base: "23%", md: "18ex" }}>
{/* A small wording tweak to fit better on the xsmall screens! */}
<Box display={{ base: "none", sm: "block" }}>Last updated</Box>
<Box display={{ base: "none", sm: "block" }}>Last active</Box>
<Box display={{ base: "block", sm: "none" }}>Updated</Box>
</ItemTradesTableCell>
<ItemTradesTableCell as="th">Compare</ItemTradesTableCell>
<ItemTradesTableCell as="th" width={{ base: "23%", md: "18ex" }}>
Compare
</ItemTradesTableCell>
</Box>
</Box>
<Box as="tbody">
<ItemTradesTableRow compareListHeading={compareListHeading} />
<ItemTradesTableRow compareListHeading={compareListHeading} />
<ItemTradesTableRow compareListHeading={compareListHeading} />
<ItemTradesTableRow compareListHeading={compareListHeading} />
<ItemTradesTableRow compareListHeading={compareListHeading} />
{loading && (
<>
<ItemTradesTableRowSkeleton />
<ItemTradesTableRowSkeleton />
<ItemTradesTableRowSkeleton />
<ItemTradesTableRowSkeleton />
<ItemTradesTableRowSkeleton />
</>
)}
{!loading &&
data.item.trades.length > 0 &&
data.item.trades.map((trade) => (
<ItemTradesTableRow
key={trade.id}
compareListHeading={compareListHeading}
href={`/user/${trade.user.id}/items#list-${trade.closetList.id}`}
username={trade.user.username}
listName={trade.closetList.name}
/>
))}
{!loading && data.item.trades.length === 0 && (
<Box as="tr">
<ItemTradesTableCell
colSpan="4"
textAlign="center"
fontStyle="italic"
>
No trades yet!
</ItemTradesTableCell>
</Box>
)}
</Box>
</Box>
);
}
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 (
<Box
as="tr"
cursor={"pointer"}
cursor="pointer"
_hover={{ background: focusBackground }}
_focusWithin={{ background: focusBackground }}
onClick={onClick}
>
<ItemTradesTableCell>Matchu</ItemTradesTableCell>
<ItemTradesTableCell>
<ItemTradesTableCell overflowWrap="break-word" fontSize="sm">
<Box
as="a"
href="/user/6/items#list-1"
href={href}
className={css`
&:hover,
&:focus,
@ -132,14 +225,17 @@ function ItemTradesTableRow({ compareListHeading }) {
}
`}
>
Top priorities and such so yeah
{listName}
</Box>
</ItemTradesTableCell>
<ItemTradesTableCell>
<ItemTradesTableCell overflowWrap="break-word" fontSize="xs">
{username}
</ItemTradesTableCell>
<ItemTradesTableCell fontSize="xs">
<Box display={{ base: "block", sm: "none" }}>&lt;1 week</Box>
<Box display={{ base: "none", sm: "block" }}>This week</Box>
</ItemTradesTableCell>
<ItemTradesTableCell height="100%">
<ItemTradesTableCell fontSize="xs">
<Tooltip
placement="bottom"
label={
@ -177,6 +273,25 @@ function ItemTradesTableRow({ compareListHeading }) {
);
}
function ItemTradesTableRowSkeleton() {
return (
<Box as="tr">
<ItemTradesTableCell>
<Skeleton width="100%">Placeholder</Skeleton>
</ItemTradesTableCell>
<ItemTradesTableCell>
<Skeleton width="100%">Placeholder</Skeleton>
</ItemTradesTableCell>
<ItemTradesTableCell>
<Skeleton width="100%">Placeholder</Skeleton>
</ItemTradesTableCell>
<ItemTradesTableCell>
<Skeleton width="100%">Placeholder</Skeleton>
</ItemTradesTableCell>
</Box>
);
}
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,

View file

@ -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"),

View file

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

View file

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

View file

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

View file

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