First draft of UserItemListPage

A lot is missing! No descriptions, no support for the "Not in a list" case, no scroll performance windowing, no editing!

But it's a start :3
This commit is contained in:
Emi Matchu 2021-06-12 04:45:23 -07:00
parent 232e35e062
commit cf30b25be0
8 changed files with 281 additions and 20 deletions

View file

@ -45,6 +45,7 @@
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-icons": "^4.2.0", "react-icons": "^4.2.0",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-router-hash-link": "^2.4.3",
"react-scripts": "^4.0.1", "react-scripts": "^4.0.1",
"react-transition-group": "^4.3.0", "react-transition-group": "^4.3.0",
"simple-markdown": "^0.7.2", "simple-markdown": "^0.7.2",

View file

@ -35,6 +35,7 @@ const SupportPetAppearancesPage = loadable(() =>
import("./SupportPetAppearancesPage") import("./SupportPetAppearancesPage")
); );
const UserItemsPage = loadable(() => import("./UserItemsPage")); const UserItemsPage = loadable(() => import("./UserItemsPage"));
const UserItemListPage = loadable(() => import("./UserItemListPage"));
const UserOutfitsPage = loadable(() => import("./UserOutfitsPage")); const UserOutfitsPage = loadable(() => import("./UserOutfitsPage"));
const WardrobePage = loadable(() => import("./WardrobePage"), { const WardrobePage = loadable(() => import("./WardrobePage"), {
fallback: <WardrobePageLayout />, fallback: <WardrobePageLayout />,
@ -135,6 +136,11 @@ function App() {
<Route path="/outfits/:id"> <Route path="/outfits/:id">
<WardrobePage /> <WardrobePage />
</Route> </Route>
<Route path="/user/:userId/lists/:ownsOrWants(owns|wants)/:listId">
<PageLayout>
<UserItemListPage />
</PageLayout>
</Route>
<Route path="/user/:userId/lists"> <Route path="/user/:userId/lists">
<PageLayout> <PageLayout>
<UserItemsPage /> <UserItemsPage />

170
src/app/UserItemListPage.js Normal file
View file

@ -0,0 +1,170 @@
import React from "react";
import {
Box,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Center,
Flex,
Wrap,
WrapItem,
} from "@chakra-ui/react";
import { Heading1, MajorErrorMessage } from "./util";
import { gql, useQuery } from "@apollo/client";
import { Link, useParams } from "react-router-dom";
import { HashLink } from "react-router-hash-link";
import HangerSpinner from "./components/HangerSpinner";
import { ChevronRightIcon } from "@chakra-ui/icons";
import ItemCard from "./components/ItemCard";
import WIPCallout from "./components/WIPCallout";
function UserItemListPage() {
const { listId } = useParams();
const { loading, error, data } = useQuery(
gql`
query UserItemListPage($listId: ID!) {
closetList(id: $listId) {
id
name
ownsOrWantsItems
creator {
id
username
}
items {
id
isNc
isPb
name
thumbnailUrl
}
}
}
`,
{ variables: { listId } }
);
if (loading) {
return (
<Center>
<HangerSpinner />
</Center>
);
}
if (error) {
return <MajorErrorMessage error={error} variant="network" />;
}
const closetList = data?.closetList;
if (!closetList) {
return <MajorErrorMessage variant="not-found" />;
}
const { creator, ownsOrWantsItems } = closetList;
let linkBackText;
let linkBackPath;
if (ownsOrWantsItems === "OWNS") {
linkBackText = `Items ${creator.username} owns`;
linkBackPath = `/user/${creator.id}/lists#owned-items`;
} else if (ownsOrWantsItems === "WANTS") {
linkBackText = `Items ${creator.username} wants`;
linkBackPath = `/user/${creator.id}/lists#wanted-items`;
} else {
throw new Error(`unexpected ownsOrWantsItems value: ${ownsOrWantsItems}`);
}
return (
<Box>
<Breadcrumb
fontSize="sm"
opacity="0.8"
separator={<ChevronRightIcon marginTop="-2px" />}
>
<BreadcrumbItem>
<BreadcrumbLink as={Link} to={`/user/${creator.id}/lists`}>
{creator.username}'s lists
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem as={HashLink} to={linkBackPath}>
<BreadcrumbLink>{linkBackText}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
<Box height="1" />
<Flex wrap="wrap">
<Heading1>{closetList.name}</Heading1>
<WIPCallout
marginLeft="auto"
details="We're planning to make this the detail view for each list, and then your lists page will be a easy-to-scan summary!"
/>
</Flex>
<Box height="6" />
{/* TODO: Description */}
<ClosetListContents closetList={closetList} />
</Box>
);
}
function ClosetListContents({ closetList }) {
const isCurrentUser = false; // TODO
// TODO: A lot of this is duplicated from UserItemsPage, find shared
// abstractions!
const hasYouWantThisBadge = (item) =>
!isCurrentUser &&
closetList.ownsOrWantsItems === "OWNS" &&
item.currentUserWantsThis;
const hasYouOwnThisBadge = (item) =>
!isCurrentUser &&
closetList.ownsOrWantsItems === "WANTS" &&
item.currentUserOwnsThis;
const hasAnyTradeBadge = (item) =>
hasYouOwnThisBadge(item) || hasYouWantThisBadge(item);
const sortedItems = [...closetList.items].sort((a, b) => {
// This is a cute sort hack. We sort first by, bringing "You own/want
// this!" to the top, and then sorting by name _within_ those two groups.
const aName = `${hasAnyTradeBadge(a) ? "000" : "999"} ${a.name}`;
const bName = `${hasAnyTradeBadge(b) ? "000" : "999"} ${b.name}`;
return aName.localeCompare(bName);
});
let tradeMatchingMode;
if (isCurrentUser) {
// On your own item list, it's not helpful to show your own trade matches!
tradeMatchingMode = "hide-all";
} else if (closetList.ownsOrWantsItems === "OWNS") {
tradeMatchingMode = "offering";
} else if (closetList.ownsOrWantsItems === "WANTS") {
tradeMatchingMode = "seeking";
} else {
throw new Error(
`unexpected ownsOrWantsItems value: ${closetList.ownsOrWantsItems}`
);
}
return (
<Box>
{sortedItems.length > 0 ? (
<Wrap spacing="4" justify="center">
{sortedItems.map((item) => (
<WrapItem key={item.id}>
<ItemCard
item={item}
variant="grid"
tradeMatchingMode={tradeMatchingMode}
/>
</WrapItem>
))}
</Wrap>
) : (
<Box fontStyle="italic">This list is empty!</Box>
)}
</Box>
);
}
export default UserItemListPage;

View file

@ -32,7 +32,7 @@ import {
StarIcon, StarIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useHistory, useParams } from "react-router-dom"; import { Link, useHistory, useParams } from "react-router-dom";
import { useQuery, useLazyQuery, useMutation } from "@apollo/client"; import { useQuery, useLazyQuery, useMutation } from "@apollo/client";
import SimpleMarkdown from "simple-markdown"; import SimpleMarkdown from "simple-markdown";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
@ -76,10 +76,9 @@ function UserItemsPage() {
thumbnailUrl thumbnailUrl
currentUserOwnsThis currentUserOwnsThis
currentUserWantsThis currentUserWantsThis
allOccupiedZones {
id
label @client
} }
creator {
id
} }
} }
} }
@ -549,8 +548,14 @@ function ClosetList({ closetList, isCurrentUser, showHeading }) {
fontStyle={closetList.isDefaultList ? "italic" : "normal"} fontStyle={closetList.isDefaultList ? "italic" : "normal"}
lineHeight="1.2" // to match Input lineHeight="1.2" // to match Input
paddingY="2px" // to account for Input border/padding paddingY="2px" // to account for Input border/padding
>
{closetList.isDefaultList ? closetList.name : <Box
as={Link}
to={buildClosetListPath(closetList)}
_hover={{ textDecoration: "underline" }}
> >
{closetList.name} {closetList.name}
</Box>}
</Heading3> </Heading3>
))} ))}
<Box flex="1 0 auto" width="4" /> <Box flex="1 0 auto" width="4" />
@ -631,6 +636,23 @@ function ClosetList({ closetList, isCurrentUser, showHeading }) {
); );
} }
function buildClosetListPath(closetList) {
let ownsOrWants;
if (closetList.ownsOrWantsItems === "OWNS") {
ownsOrWants = "owns";
} else if (closetList.ownsOrWantsItems === "WANTS") {
ownsOrWants = "wants";
} else {
throw new Error(
`unexpected ownsOrWantsItems value: ${closetList.ownsOrWantsItems}`
);
}
const idString = closetList.isDefaultList ? "not-in-a-list" : closetList.id;
return `/user/${closetList.creator.id}/lists/${ownsOrWants}/${idString}`;
}
const unsafeMarkdownRules = { const unsafeMarkdownRules = {
autolink: SimpleMarkdown.defaultRules.autolink, autolink: SimpleMarkdown.defaultRules.autolink,
br: SimpleMarkdown.defaultRules.br, br: SimpleMarkdown.defaultRules.br,

View file

@ -28,7 +28,6 @@ function WIPCallout({
paddingRight="4" paddingRight="4"
paddingY="1" paddingY="1"
fontSize={size === "sm" ? "xs" : "sm"} fontSize={size === "sm" ? "xs" : "sm"}
{...props}
> >
<Box <Box
as="img" as="img"
@ -62,6 +61,8 @@ function WIPCallout({
); );
} }
content = <Box {...props}>{content}</Box>;
return content; return content;
} }

View file

@ -15,6 +15,21 @@ const buildClosetListLoader = (db) =>
return ids.map((id) => entities.find((e) => e.id === id)); return ids.map((id) => entities.find((e) => e.id === id));
}); });
const buildClosetHangersForListLoader = (db) =>
new DataLoader(async (closetListIds) => {
const qs = closetListIds.map((_) => "?").join(",");
const [rows] = await db.execute(
`SELECT * FROM closet_hangers WHERE list_id IN (${qs})`,
closetListIds
);
const entities = rows.map(normalizeRow);
return closetListIds.map((closetListId) =>
entities.filter((e) => e.listId === closetListId)
);
});
const buildColorLoader = (db) => { const buildColorLoader = (db) => {
const colorLoader = new DataLoader(async (colorIds) => { const colorLoader = new DataLoader(async (colorIds) => {
const qs = colorIds.map((_) => "?").join(","); const qs = colorIds.map((_) => "?").join(",");
@ -1227,6 +1242,7 @@ function buildLoaders(db) {
loaders.loadAllPetTypes = loadAllPetTypes(db); loaders.loadAllPetTypes = loadAllPetTypes(db);
loaders.closetListLoader = buildClosetListLoader(db); loaders.closetListLoader = buildClosetListLoader(db);
loaders.closetHangersForListLoader = buildClosetHangersForListLoader(db);
loaders.colorLoader = buildColorLoader(db); loaders.colorLoader = buildColorLoader(db);
loaders.colorTranslationLoader = buildColorTranslationLoader(db); loaders.colorTranslationLoader = buildColorTranslationLoader(db);
loaders.itemLoader = buildItemLoader(db); loaders.itemLoader = buildItemLoader(db);

View file

@ -32,6 +32,15 @@ const typeDefs = gql`
isDefaultList: Boolean! isDefaultList: Boolean!
items: [Item!]! items: [Item!]!
# The user that created this list.
creator: User!
}
extend type Query {
# The closet list with the given ID. Will be null if it doesn't exist, or
# if you're not allowed to see it.
closetList(id: ID!): ClosetList
} }
extend type Mutation { extend type Mutation {
@ -93,22 +102,51 @@ const resolvers = {
return Boolean(isDefaultList); return Boolean(isDefaultList);
}, },
items: ({ items }) => { items: async (
{ id, items: precomputedItems },
_,
{ itemLoader, closetHangersForListLoader }
) => {
// HACK: When called from User.js, for fetching all of a user's lists at // 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 // once, this is provided in the returned object. Just use it!
// we separated out the ClosetList resolvers at all! But I'm not // TODO: Might be better to prime the loader with this instead?
// bothering to port it, because it would mean writing a new if (precomputedItems) {
// loader, and we don't yet have any endpoints that actually need return precomputedItems;
// this.
if (items) {
return items;
} }
throw new Error( // TODO: Support the not-in-a-list case!
`TODO: Not implemented, we still duplicate / bulk-implement some of ` + const closetHangers = await closetHangersForListLoader.load(id);
`the list resolver stuff in User.js. Break that out into real ` + const itemIds = closetHangers.map((h) => h.itemId);
`ClosetList loaders and resolvers!` const items = await itemLoader.loadMany(itemIds);
);
return items.map(({ id }) => ({ id }));
},
creator: async ({ id, isDefaultList, userId }, _, { closetListLoader }) => {
if (isDefaultList) {
return { id: userId };
}
const closetList = await closetListLoader.load(id);
return { id: closetList.userId };
},
},
Query: {
closetList: async (_, { id, currentUserId }, { closetListLoader }) => {
// TODO: Accept the `not-in-a-list` case too!
const closetList = await closetListLoader.load(id);
if (!closetList) {
return null;
}
const canView =
closetList.userId === currentUserId || closetList.visibility >= 1;
if (!canView) {
return null;
}
return { id };
}, },
}, },

View file

@ -16025,6 +16025,13 @@ react-router-dom@^5.1.2:
tiny-invariant "^1.0.2" tiny-invariant "^1.0.2"
tiny-warning "^1.0.0" tiny-warning "^1.0.0"
react-router-hash-link@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/react-router-hash-link/-/react-router-hash-link-2.4.3.tgz#570824d53d6c35ce94d73a46c8e98673a127bf08"
integrity sha512-NU7GWc265m92xh/aYD79Vr1W+zAIXDWp3L2YZOYP4rCqPnJ6LI6vh3+rKgkidtYijozHclaEQTAHaAaMWPVI4A==
dependencies:
prop-types "^15.7.2"
react-router@5.1.2: react-router@5.1.2:
version "5.1.2" version "5.1.2"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418"