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