can search for user by name from items page

This commit is contained in:
Emi Matchu 2020-11-18 06:45:33 -08:00
parent f1a8277c22
commit 6a43f92438
3 changed files with 216 additions and 80 deletions

View file

@ -1,10 +1,29 @@
import React from "react"; import React from "react";
import { css } from "emotion"; import { css } from "emotion";
import { Badge, Box, Center, Wrap, VStack } from "@chakra-ui/core"; import {
import { CheckIcon, EmailIcon, StarIcon } from "@chakra-ui/icons"; Badge,
Box,
Center,
Flex,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
Wrap,
VStack,
useToast,
} from "@chakra-ui/core";
import {
ArrowForwardIcon,
CheckIcon,
EmailIcon,
SearchIcon,
StarIcon,
} from "@chakra-ui/icons";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useParams } from "react-router-dom"; import { useHistory, useParams } from "react-router-dom";
import { useQuery } from "@apollo/client"; import { useQuery, useLazyQuery } from "@apollo/client";
import SimpleMarkdown from "simple-markdown"; import SimpleMarkdown from "simple-markdown";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
@ -117,84 +136,95 @@ function UserItemsPage() {
return ( return (
<Box> <Box>
{isCurrentUser && ( <Flex align="center" wrap="wrap-reverse">
<Box float="right"> <Box>
<WIPCallout details="These lists are read-only for now. To edit, head back to Classic DTI!" /> <Heading1>
{isCurrentUser ? "Your items" : `${data.user.username}'s items`}
</Heading1>
<Wrap spacing="2" opacity="0.7">
{data.user.contactNeopetsUsername && (
<Badge
as="a"
href={`http://www.neopets.com/userlookup.phtml?user=${data.user.contactNeopetsUsername}`}
display="flex"
alignItems="center"
>
<NeopetsStarIcon marginRight="1" />
{data.user.contactNeopetsUsername}
</Badge>
)}
{data.user.contactNeopetsUsername && (
<Badge
as="a"
href={`http://www.neopets.com/neomessages.phtml?type=send&recipient=${data.user.contactNeopetsUsername}`}
display="flex"
alignItems="center"
>
<EmailIcon marginRight="1" />
Neomail
</Badge>
)}
{/* Usually I put "Own" before "Want", but this matches the natural
* order on the page: the _matches_ for things you want are things
* _this user_ owns, so they come first. I think it's also probably a
* more natural train of thought: you come to someone's list _wanting_
* something, and _then_ thinking about what you can offer. */}
{!isCurrentUser && numItemsTheyOwnThatYouWant > 0 && (
<Badge
as="a"
href="#owned-items"
colorScheme="blue"
display="flex"
alignItems="center"
>
<StarIcon marginRight="1" />
{numItemsTheyOwnThatYouWant > 1
? `${numItemsTheyOwnThatYouWant} items you want`
: "1 item you want"}
</Badge>
)}
{!isCurrentUser && numItemsTheyWantThatYouOwn > 0 && (
<Badge
as="a"
href="#wanted-items"
colorScheme="green"
display="flex"
alignItems="center"
>
<CheckIcon marginRight="1" />
{numItemsTheyWantThatYouOwn > 1
? `${numItemsTheyWantThatYouOwn} items you own`
: "1 item you own"}
</Badge>
)}
</Wrap>
</Box> </Box>
)} <Box flex="1 0 auto" width="2" />
<Heading1> <Box marginBottom="1">
{isCurrentUser ? "Your items" : `${data.user.username}'s items`} <UserSearchForm />
</Heading1> </Box>
<Wrap spacing="2" opacity="0.7"> </Flex>
{data.user.contactNeopetsUsername && (
<Badge <Box marginTop="4">
as="a" {isCurrentUser && (
href={`http://www.neopets.com/userlookup.phtml?user=${data.user.contactNeopetsUsername}`} <Box float="right">
display="flex" <WIPCallout details="These lists are read-only for now. To edit, head back to Classic DTI!" />
alignItems="center" </Box>
>
<NeopetsStarIcon marginRight="1" />
{data.user.contactNeopetsUsername}
</Badge>
)} )}
{data.user.contactNeopetsUsername && ( <Heading2 id="owned-items" marginBottom="2">
<Badge {isCurrentUser ? "Items you own" : `Items ${data.user.username} owns`}
as="a" </Heading2>
href={`http://www.neopets.com/neomessages.phtml?type=send&recipient=${data.user.contactNeopetsUsername}`} <VStack spacing="8" alignItems="stretch">
display="flex" {listsOfOwnedItems.map((closetList) => (
alignItems="center" <ClosetList
> key={closetList.id}
<EmailIcon marginRight="1" /> closetList={closetList}
Neomail isCurrentUser={isCurrentUser}
</Badge> showHeading={listsOfOwnedItems.length > 1}
)} />
{/* Usually I put "Own" before "Want", but this matches the natural ))}
* order on the page: the _matches_ for things you want are things </VStack>
* _this user_ owns, so they come first. I think it's also probably a </Box>
* more natural train of thought: you come to someone's list _wanting_
* something, and _then_ thinking about what you can offer. */}
{!isCurrentUser && numItemsTheyOwnThatYouWant > 0 && (
<Badge
as="a"
href="#owned-items"
colorScheme="blue"
display="flex"
alignItems="center"
>
<StarIcon marginRight="1" />
{numItemsTheyOwnThatYouWant > 1
? `${numItemsTheyOwnThatYouWant} items you want`
: "1 item you want"}
</Badge>
)}
{!isCurrentUser && numItemsTheyWantThatYouOwn > 0 && (
<Badge
as="a"
href="#wanted-items"
colorScheme="green"
display="flex"
alignItems="center"
>
<CheckIcon marginRight="1" />
{numItemsTheyWantThatYouOwn > 1
? `${numItemsTheyWantThatYouOwn} items you own`
: "1 item you own"}
</Badge>
)}
</Wrap>
<Heading2 id="owned-items" marginTop="4" marginBottom="2">
{isCurrentUser ? "Items you own" : `Items ${data.user.username} owns`}
</Heading2>
<VStack spacing="8" alignItems="stretch">
{listsOfOwnedItems.map((closetList) => (
<ClosetList
key={closetList.id}
closetList={closetList}
isCurrentUser={isCurrentUser}
showHeading={listsOfOwnedItems.length > 1}
/>
))}
</VStack>
<Heading2 id="wanted-items" marginTop="10" marginBottom="2"> <Heading2 id="wanted-items" marginTop="10" marginBottom="2">
{isCurrentUser ? "Items you want" : `Items ${data.user.username} wants`} {isCurrentUser ? "Items you want" : `Items ${data.user.username} wants`}
@ -213,6 +243,85 @@ function UserItemsPage() {
); );
} }
function UserSearchForm() {
const [query, setQuery] = React.useState("");
const history = useHistory();
const toast = useToast();
const [loadUserSearch, { loading }] = useLazyQuery(
gql`
query UserSearchForm($name: String!) {
userByName(name: $name) {
id
# Consider preloading UserItemsPage fields here, too?
}
}
`,
{
onCompleted: (data) => {
const user = data.userByName;
if (!user) {
toast({
status: "warning",
title: "We couldn't find that user!",
description: "Check the spelling and try again?",
});
return;
}
history.push(`/user/${user.id}/items`);
},
onError: (error) => {
console.error(error);
toast({
status: "error",
title: "Error loading user!",
description: "Check your connection and try again?",
});
},
}
);
return (
<Box
as="form"
onSubmit={(e) => {
loadUserSearch({ variables: { name: query } });
e.preventDefault();
}}
>
<InputGroup size="sm">
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.400" />
</InputLeftElement>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for another user…"
borderRadius="full"
/>
<InputRightElement>
<IconButton
type="submit"
variant="ghost"
icon={<ArrowForwardIcon />}
aria-label="Search"
isLoading={loading}
minWidth="1.5rem"
minHeight="1.5rem"
width="1.5rem"
height="1.5rem"
borderRadius="full"
opacity={query ? 1 : 0}
transition="opacity 0.2s"
aria-hidden={query ? "false" : "true"}
/>
</InputRightElement>
</InputGroup>
</Box>
);
}
function ClosetList({ closetList, isCurrentUser, showHeading }) { function ClosetList({ closetList, isCurrentUser, showHeading }) {
const hasYouWantThisBadge = (item) => const hasYouWantThisBadge = (item) =>
!isCurrentUser && !isCurrentUser &&

View file

@ -759,6 +759,21 @@ const buildUserLoader = (db) =>
); );
}); });
const buildUserByNameLoader = (db) =>
new DataLoader(async (names) => {
const qs = names.map((_) => "?").join(",");
const [rows, _] = await db.execute(
`SELECT * FROM users WHERE name IN (${qs})`,
names
);
const entities = rows.map(normalizeRow);
return names.map((name) =>
entities.find((e) => e.name.toLowerCase() === name.toLowerCase())
);
});
const buildUserClosetHangersLoader = (db) => const buildUserClosetHangersLoader = (db) =>
new DataLoader(async (userIds) => { new DataLoader(async (userIds) => {
const qs = userIds.map((_) => "?").join(","); const qs = userIds.map((_) => "?").join(",");
@ -891,6 +906,7 @@ function buildLoaders(db) {
loaders.speciesLoader = buildSpeciesLoader(db); loaders.speciesLoader = buildSpeciesLoader(db);
loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db); loaders.speciesTranslationLoader = buildSpeciesTranslationLoader(db);
loaders.userLoader = buildUserLoader(db); loaders.userLoader = buildUserLoader(db);
loaders.userByNameLoader = buildUserByNameLoader(db);
loaders.userClosetHangersLoader = buildUserClosetHangersLoader(db); loaders.userClosetHangersLoader = buildUserClosetHangersLoader(db);
loaders.userClosetListsLoader = buildUserClosetListsLoader(db); loaders.userClosetListsLoader = buildUserClosetListsLoader(db);
loaders.zoneLoader = buildZoneLoader(db); loaders.zoneLoader = buildZoneLoader(db);

View file

@ -40,6 +40,7 @@ const typeDefs = gql`
extend type Query { extend type Query {
user(id: ID!): User user(id: ID!): User
userByName(name: String!): User
currentUser: User currentUser: User
} }
`; `;
@ -213,6 +214,16 @@ const resolvers = {
return { id }; return { id };
}, },
userByName: async (_, { name }, { userByNameLoader }) => {
const user = await userByNameLoader.load(name);
if (!user) {
return null;
}
return { id: user.id };
},
currentUser: async (_, __, { currentUserId, userLoader }) => { currentUser: async (_, __, { currentUserId, userLoader }) => {
if (currentUserId == null) { if (currentUserId == null) {
return null; return null;