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