diff --git a/src/app/UserOutfitsPage.js b/src/app/UserOutfitsPage.js
index f06f696..3c929b4 100644
--- a/src/app/UserOutfitsPage.js
+++ b/src/app/UserOutfitsPage.js
@@ -3,21 +3,18 @@ import { Box, Center, Flex, Wrap, WrapItem } from "@chakra-ui/react";
import { ClassNames } from "@emotion/react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
-import { Link } from "react-router-dom";
+import { Link, useLocation } from "react-router-dom";
import { Heading1, MajorErrorMessage, useCommonStyles } from "./util";
import HangerSpinner from "./components/HangerSpinner";
import OutfitThumbnail from "./components/OutfitThumbnail";
import useRequireLogin from "./components/useRequireLogin";
-import WIPCallout from "./components/WIPCallout";
+import PaginationToolbar from "./components/PaginationToolbar";
function UserOutfitsPage() {
return (
-
- Your outfits
-
-
+ Your outfits
);
@@ -26,12 +23,16 @@ function UserOutfitsPage() {
function UserOutfitsPageContent() {
const { isLoading: userLoading } = useRequireLogin();
+ const { search } = useLocation();
+ const offset = parseInt(new URLSearchParams(search).get("offset")) || 0;
+
const { loading: queryLoading, error, data } = useQuery(
gql`
- query UserOutfitsPageContent {
+ query UserOutfitsPageContent($offset: Int!) {
currentUser {
id
- outfits {
+ numTotalOutfits
+ outfits(limit: 20, offset: $offset) {
id
name
updatedAt
@@ -56,39 +57,53 @@ function UserOutfitsPageContent() {
}
`,
{
+ variables: { offset },
context: { sendAuth: true },
skip: userLoading,
+ // This will give us the cached numTotalOutfits while we wait for the
+ // next page!
+ returnPartialData: true,
}
);
- if (userLoading || queryLoading) {
- return (
-
-
-
- );
- }
+ const isLoading = userLoading || queryLoading;
if (error) {
return ;
}
- const outfits = data.currentUser.outfits;
-
- if (outfits.length === 0) {
- return (
- You don't have any outfits yet. Maybe you can create some!
- );
- }
+ const outfits = data?.currentUser?.outfits || [];
return (
-
- {outfits.map((outfit) => (
-
-
-
- ))}
-
+
+
+
+ {isLoading ? (
+
+
+
+ ) : outfits.length === 0 ? (
+ You don't have any outfits yet. Maybe you can create some!
+ ) : (
+
+ {outfits.map((outfit) => (
+
+
+
+ ))}
+
+ )}
+
+
+
);
}
diff --git a/src/app/components/PaginationToolbar.js b/src/app/components/PaginationToolbar.js
index 0d5089d..560f66a 100644
--- a/src/app/components/PaginationToolbar.js
+++ b/src/app/components/PaginationToolbar.js
@@ -2,26 +2,29 @@ import React from "react";
import { Box, Button, Flex, Select } from "@chakra-ui/react";
import { Link, useHistory, useLocation } from "react-router-dom";
-const PER_PAGE = 30;
-
-function PaginationToolbar({ isLoading, totalCount, ...props }) {
+function PaginationToolbar({
+ isLoading,
+ totalCount,
+ numPerPage = 30,
+ ...props
+}) {
const { search } = useLocation();
const history = useHistory();
const currentOffset =
parseInt(new URLSearchParams(search).get("offset")) || 0;
- const currentPageIndex = Math.floor(currentOffset / PER_PAGE);
+ const currentPageIndex = Math.floor(currentOffset / numPerPage);
const currentPageNumber = currentPageIndex + 1;
- const numTotalPages = totalCount ? Math.ceil(totalCount / PER_PAGE) : null;
+ const numTotalPages = totalCount ? Math.ceil(totalCount / numPerPage) : null;
const prevPageSearchParams = new URLSearchParams(search);
- const prevPageOffset = currentOffset - PER_PAGE;
+ const prevPageOffset = currentOffset - numPerPage;
prevPageSearchParams.set("offset", prevPageOffset);
const prevPageUrl = "?" + prevPageSearchParams.toString();
const nextPageSearchParams = new URLSearchParams(search);
- const nextPageOffset = currentOffset + PER_PAGE;
+ const nextPageOffset = currentOffset + numPerPage;
nextPageSearchParams.set("offset", nextPageOffset);
const nextPageUrl = "?" + nextPageSearchParams.toString();
@@ -37,13 +40,13 @@ function PaginationToolbar({ isLoading, totalCount, ...props }) {
const goToPageNumber = React.useCallback(
(newPageNumber) => {
const newPageIndex = newPageNumber - 1;
- const newPageOffset = newPageIndex * PER_PAGE;
+ const newPageOffset = newPageIndex * numPerPage;
const newPageSearchParams = new URLSearchParams(search);
newPageSearchParams.set("offset", newPageOffset);
history.push({ search: newPageSearchParams.toString() });
},
- [search, history]
+ [search, history, numPerPage]
);
return (
diff --git a/src/server/loaders.js b/src/server/loaders.js
index 61c59dc..1477c05 100644
--- a/src/server/loaders.js
+++ b/src/server/loaders.js
@@ -1291,23 +1291,45 @@ const buildUserClosetListsLoader = (db, loaders) =>
});
const buildUserOutfitsLoader = (db, loaders) =>
+ new DataLoader(async (queries) => {
+ // This isn't actually optimized as a batch query, we're just using a
+ // DataLoader API consistency with our other loaders!
+ return queries.map(async ({ userId, limit, offset }) => {
+ const actualLimit = Math.min(limit || 30, 30);
+ const actualOffset = offset || 0;
+
+ const [rows] = await db.execute(
+ `SELECT * FROM outfits
+ WHERE user_id = ?
+ ORDER BY name
+ LIMIT ? OFFSET ?`,
+ [userId, actualLimit, actualOffset]
+ );
+
+ const entities = rows.map(normalizeRow);
+ for (const entity of entities) {
+ loaders.outfitLoader.prime(entity.id, entity);
+ }
+
+ return entities;
+ });
+ });
+
+const buildUserNumTotalOutfitsLoader = (db) =>
new DataLoader(async (userIds) => {
const qs = userIds.map((_) => "?").join(",");
const [rows] = await db.execute(
- `SELECT * FROM outfits
+ `SELECT user_id, COUNT(*) as num_total_outfits FROM outfits
WHERE user_id IN (${qs})
- ORDER BY name`,
+ GROUP BY user_id`,
userIds
);
const entities = rows.map(normalizeRow);
- for (const entity of entities) {
- loaders.outfitLoader.prime(entity.id, entity);
- }
- return userIds.map((userId) =>
- entities.filter((e) => e.userId === String(userId))
- );
+ return userIds
+ .map((userId) => entities.find((e) => e.userId === String(userId)))
+ .map((e) => (e ? e.numTotalOutfits : 0));
});
const buildUserLastTradeActivityLoader = (db) =>
@@ -1486,6 +1508,7 @@ function buildLoaders(db) {
loaders.userByEmailLoader = buildUserByEmailLoader(db);
loaders.userClosetHangersLoader = buildUserClosetHangersLoader(db);
loaders.userClosetListsLoader = buildUserClosetListsLoader(db, loaders);
+ loaders.userNumTotalOutfitsLoader = buildUserNumTotalOutfitsLoader(db);
loaders.userOutfitsLoader = buildUserOutfitsLoader(db, loaders);
loaders.userLastTradeActivityLoader = buildUserLastTradeActivityLoader(db);
loaders.zoneLoader = buildZoneLoader(db);
diff --git a/src/server/types/User.js b/src/server/types/User.js
index 2fb5f89..ac81383 100644
--- a/src/server/types/User.js
+++ b/src/server/types/User.js
@@ -28,9 +28,16 @@ const typeDefs = gql`
"""
This user's outfits. Returns an empty list if the current user is not
+ authorized to see them. This list is paginated, and will return at most 30
+ at once.
+ """
+ outfits(limit: Int, offset: Int): [Outfit!]!
+
+ """
+ The number of outfits this user has. Returns 0 if the current user is not
authorized to see them.
"""
- outfits: [Outfit!]!
+ numTotalOutfits: Int!
"This user's email address. Requires the correct supportSecret to view."
emailForSupportUsers(supportSecret: String!): String!
@@ -241,15 +248,36 @@ const resolvers = {
return lastTradeActivity.toISOString();
},
- outfits: async ({ id }, _, { currentUserId, userOutfitsLoader }) => {
+ outfits: async (
+ { id },
+ { limit = 30, offset = 0 },
+ { currentUserId, userOutfitsLoader }
+ ) => {
if (currentUserId !== id) {
return [];
}
- const outfits = await userOutfitsLoader.load(id);
+ const outfits = await userOutfitsLoader.load({
+ userId: id,
+ limit,
+ offset,
+ });
return outfits.map((outfit) => ({ id: outfit.id }));
},
+ numTotalOutfits: async (
+ { id },
+ _,
+ { currentUserId, userNumTotalOutfitsLoader }
+ ) => {
+ if (currentUserId !== id) {
+ return [];
+ }
+
+ const numTotalOutfits = await userNumTotalOutfitsLoader.load(id);
+ return numTotalOutfits;
+ },
+
emailForSupportUsers: async ({ id }, { supportSecret }, { db }) => {
assertSupportSecretOrThrow(supportSecret);