Editable name/desc on list page
Decided to share the ClosetList component and give it a bit of variance, instead of figuring out how to extract all that edit state!
This commit is contained in:
parent
faf8364aab
commit
d91492ab66
2 changed files with 250 additions and 233 deletions
|
@ -5,25 +5,34 @@ import {
|
|||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
HStack,
|
||||
Input,
|
||||
Textarea,
|
||||
useToast,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ArrowForwardIcon,
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { Heading1, MajorErrorMessage, usePageTitle } from "./util";
|
||||
import { gql, useQuery } from "@apollo/client";
|
||||
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { HashLink } from "react-router-hash-link";
|
||||
|
||||
import { Heading1, Heading3, MajorErrorMessage, usePageTitle } from "./util";
|
||||
import HangerSpinner from "./components/HangerSpinner";
|
||||
import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML";
|
||||
import ItemCard from "./components/ItemCard";
|
||||
import useCurrentUser from "./components/useCurrentUser";
|
||||
import useSupport from "./WardrobePage/support/useSupport";
|
||||
import WIPCallout from "./components/WIPCallout";
|
||||
|
||||
function UserItemListPage() {
|
||||
const { listId } = useParams();
|
||||
|
@ -112,42 +121,240 @@ function UserItemListPage() {
|
|||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Box height="1" />
|
||||
<Heading1>{closetList.name}</Heading1>
|
||||
<Wrap spacing="2" opacity="0.7">
|
||||
{closetList.creator?.contactNeopetsUsername && (
|
||||
<WrapItem>
|
||||
<Badge
|
||||
as="a"
|
||||
href={`http://www.neopets.com/userlookup.phtml?user=${closetList.creator.contactNeopetsUsername}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
<ClosetList
|
||||
closetList={closetList}
|
||||
isCurrentUser={isCurrentUser}
|
||||
headingVariant="top-level"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClosetList({
|
||||
closetList,
|
||||
isCurrentUser,
|
||||
headingVariant = "list-item",
|
||||
}) {
|
||||
const { isSupportUser, supportSecret } = useSupport();
|
||||
const toast = useToast();
|
||||
|
||||
// When this mounts, scroll it into view if it matches the location hash.
|
||||
// This works around the fact that, while the browser tries to do this
|
||||
// natively on page load, the list might not be mounted yet!
|
||||
const anchorId = `list-${closetList.id}`;
|
||||
React.useEffect(() => {
|
||||
if (document.location.hash === "#" + anchorId) {
|
||||
document.getElementById(anchorId).scrollIntoView();
|
||||
}
|
||||
}, [anchorId]);
|
||||
|
||||
const [
|
||||
sendSaveChangesMutation,
|
||||
{ loading: loadingSaveChanges },
|
||||
] = useMutation(
|
||||
gql`
|
||||
mutation ClosetList_Edit(
|
||||
$closetListId: ID!
|
||||
$name: String!
|
||||
$description: String!
|
||||
# Support users can edit any list, if they provide the secret. If you're
|
||||
# editing your own list, this will be empty, and that's okay.
|
||||
$supportSecret: String
|
||||
) {
|
||||
editClosetList(
|
||||
closetListId: $closetListId
|
||||
name: $name
|
||||
description: $description
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ context: { sendAuth: true } }
|
||||
);
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [editableName, setEditableName] = React.useState(closetList.name);
|
||||
const [editableDescription, setEditableDescription] = React.useState(
|
||||
closetList.description
|
||||
);
|
||||
const hasChanges =
|
||||
editableName !== closetList.name ||
|
||||
editableDescription !== closetList.description;
|
||||
const onSaveChanges = () => {
|
||||
if (!hasChanges) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
sendSaveChangesMutation({
|
||||
variables: {
|
||||
closetListId: closetList.id,
|
||||
name: editableName,
|
||||
description: editableDescription,
|
||||
supportSecret,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
setIsEditing(false);
|
||||
toast({
|
||||
status: "success",
|
||||
title: "Changes saved!",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Sorry, we couldn't save this list 😖",
|
||||
description: "Check your connection and try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const Heading = headingVariant === "top-level" ? Heading1 : Heading3;
|
||||
|
||||
return (
|
||||
<Box id={anchorId}>
|
||||
<Flex align="center" wrap="wrap">
|
||||
{headingVariant !== "hidden" &&
|
||||
(isEditing ? (
|
||||
<Heading
|
||||
as={Input}
|
||||
value={editableName}
|
||||
onChange={(e) => setEditableName(e.target.value)}
|
||||
maxWidth="20ch"
|
||||
// Shift left by our own padding/border, for alignment with the
|
||||
// original title
|
||||
paddingX="0.75rem"
|
||||
marginLeft="calc(-0.75rem - 1px)"
|
||||
boxShadow="sm"
|
||||
lineHeight="1.2"
|
||||
// HACK: Idk, the height stuff is really getting away from me,
|
||||
// this is close enough :/
|
||||
height="1.2em"
|
||||
/>
|
||||
) : (
|
||||
<Heading
|
||||
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
|
||||
lineHeight="1.2" // to match Input
|
||||
paddingY="2px" // to account for Input border/padding
|
||||
>
|
||||
<NeopetsStarIcon marginRight="1" />
|
||||
{closetList.creator.contactNeopetsUsername}
|
||||
</Badge>
|
||||
</WrapItem>
|
||||
)}
|
||||
{closetList.creator?.contactNeopetsUsername && (
|
||||
<WrapItem>
|
||||
<Badge
|
||||
as="a"
|
||||
href={`http://www.neopets.com/neomessages.phtml?type=send&recipient=${closetList.creator.contactNeopetsUsername}`}
|
||||
{closetList.isDefaultList || headingVariant === "top-level" ? (
|
||||
closetList.name
|
||||
) : (
|
||||
<Box
|
||||
as={Link}
|
||||
to={buildClosetListPath(closetList)}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
{closetList.name}
|
||||
</Box>
|
||||
)}
|
||||
</Heading>
|
||||
))}
|
||||
<Box flex="1 0 auto" width="4" />
|
||||
{(isCurrentUser || isSupportUser) &&
|
||||
!closetList.isDefaultList &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<WIPCallout
|
||||
size="sm"
|
||||
details="To edit the items, head back to Classic DTI!"
|
||||
marginY="2"
|
||||
>
|
||||
WIP: Can only edit text for now!
|
||||
</WIPCallout>
|
||||
<Box width="4" />
|
||||
<HStack spacing="2" marginLeft="auto" marginY="1">
|
||||
<Button size="sm" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
display="flex"
|
||||
align="center"
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
onClick={onSaveChanges}
|
||||
isLoading={loadingSaveChanges}
|
||||
>
|
||||
<CheckIcon marginRight="1" />
|
||||
Save changes
|
||||
</Button>
|
||||
</HStack>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
align="center"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<EmailIcon marginRight="1" />
|
||||
Neomail
|
||||
</Badge>
|
||||
</WrapItem>
|
||||
)}
|
||||
</Wrap>
|
||||
<Box height="6" />
|
||||
<EditIcon marginRight="1" />
|
||||
Edit
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
{headingVariant === "top-level" && (
|
||||
<Wrap spacing="2" opacity="0.7" marginBottom="2">
|
||||
{closetList.creator?.contactNeopetsUsername && (
|
||||
<WrapItem>
|
||||
<Badge
|
||||
as="a"
|
||||
href={`http://www.neopets.com/userlookup.phtml?user=${closetList.creator.contactNeopetsUsername}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
>
|
||||
<NeopetsStarIcon marginRight="1" />
|
||||
{closetList.creator.contactNeopetsUsername}
|
||||
</Badge>
|
||||
</WrapItem>
|
||||
)}
|
||||
{closetList.creator?.contactNeopetsUsername && (
|
||||
<WrapItem>
|
||||
<Badge
|
||||
as="a"
|
||||
href={`http://www.neopets.com/neomessages.phtml?type=send&recipient=${closetList.creator.contactNeopetsUsername}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
>
|
||||
<EmailIcon marginRight="1" />
|
||||
Neomail
|
||||
</Badge>
|
||||
</WrapItem>
|
||||
)}
|
||||
</Wrap>
|
||||
)}
|
||||
<Box height="2" />
|
||||
{closetList.description && (
|
||||
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
|
||||
<Box marginBottom="2">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={editableDescription}
|
||||
onChange={(e) => setEditableDescription(e.target.value)}
|
||||
// Shift left by our own padding/border, for alignment with the
|
||||
// original title
|
||||
paddingX="0.75rem"
|
||||
marginLeft="calc(-0.75rem - 1px)"
|
||||
boxShadow="sm"
|
||||
/>
|
||||
) : (
|
||||
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<ClosetListContents
|
||||
closetList={closetList}
|
||||
isCurrentUser={isCurrentUser}
|
||||
// For default lists, we don't have a separate page, we just inline
|
||||
// them all here. This is a less-nice experience, but it simplifies
|
||||
// the single-list page a lot to not have to care, and for now we just
|
||||
// kinda expect that people who care about trade lists enough will
|
||||
// group them into lists so it's nbd! ^_^`
|
||||
maxNumItemsToShow={!closetList.isDefaultList ? 14 : null}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
@ -19,9 +19,6 @@ import {
|
|||
WrapItem,
|
||||
VStack,
|
||||
useToast,
|
||||
Button,
|
||||
Textarea,
|
||||
HStack,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ArrowForwardIcon,
|
||||
|
@ -32,21 +29,15 @@ import {
|
|||
StarIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import gql from "graphql-tag";
|
||||
import { Link, useHistory, useParams } from "react-router-dom";
|
||||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { useQuery, useLazyQuery, useMutation } from "@apollo/client";
|
||||
|
||||
import HangerSpinner from "./components/HangerSpinner";
|
||||
import { Heading1, Heading2, Heading3, usePageTitle } from "./util";
|
||||
import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML";
|
||||
import { Heading1, Heading2, usePageTitle } from "./util";
|
||||
import SupportOnly from "./WardrobePage/support/SupportOnly";
|
||||
import useSupport from "./WardrobePage/support/useSupport";
|
||||
import useCurrentUser from "./components/useCurrentUser";
|
||||
import WIPCallout from "./components/WIPCallout";
|
||||
import {
|
||||
ClosetListContents,
|
||||
NeopetsStarIcon,
|
||||
buildClosetListPath,
|
||||
} from "./UserItemListPage";
|
||||
import { ClosetList, NeopetsStarIcon } from "./UserItemListPage";
|
||||
|
||||
const BadgeButton = React.forwardRef((props, ref) => (
|
||||
<Badge as="button" ref={ref} {...props} />
|
||||
|
@ -269,7 +260,11 @@ function UserItemListsIndexPage() {
|
|||
key={closetList.id}
|
||||
closetList={closetList}
|
||||
isCurrentUser={isCurrentUser}
|
||||
showHeading={listsOfOwnedItems.length > 1}
|
||||
headingVariant={
|
||||
closetList.isDefaultList && listsOfOwnedItems.length === 1
|
||||
? "hidden"
|
||||
: "list-item"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
|
@ -292,7 +287,11 @@ function UserItemListsIndexPage() {
|
|||
key={closetList.id}
|
||||
closetList={closetList}
|
||||
isCurrentUser={isCurrentUser}
|
||||
showHeading={listsOfWantedItems.length > 1}
|
||||
headingVariant={
|
||||
closetList.isDefaultList && listsOfWantedItems.length === 1
|
||||
? "hidden"
|
||||
: "list-item"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
|
@ -430,195 +429,6 @@ function UserSearchForm() {
|
|||
);
|
||||
}
|
||||
|
||||
function ClosetList({ closetList, isCurrentUser, showHeading }) {
|
||||
const { isSupportUser, supportSecret } = useSupport();
|
||||
const toast = useToast();
|
||||
|
||||
// When this mounts, scroll it into view if it matches the location hash.
|
||||
// This works around the fact that, while the browser tries to do this
|
||||
// natively on page load, the list might not be mounted yet!
|
||||
const anchorId = `list-${closetList.id}`;
|
||||
React.useEffect(() => {
|
||||
if (document.location.hash === "#" + anchorId) {
|
||||
document.getElementById(anchorId).scrollIntoView();
|
||||
}
|
||||
}, [anchorId]);
|
||||
|
||||
const [
|
||||
sendSaveChangesMutation,
|
||||
{ loading: loadingSaveChanges },
|
||||
] = useMutation(
|
||||
gql`
|
||||
mutation ClosetList_Edit(
|
||||
$closetListId: ID!
|
||||
$name: String!
|
||||
$description: String!
|
||||
# Support users can edit any list, if they provide the secret. If you're
|
||||
# editing your own list, this will be empty, and that's okay.
|
||||
$supportSecret: String
|
||||
) {
|
||||
editClosetList(
|
||||
closetListId: $closetListId
|
||||
name: $name
|
||||
description: $description
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ context: { sendAuth: true } }
|
||||
);
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [editableName, setEditableName] = React.useState(closetList.name);
|
||||
const [editableDescription, setEditableDescription] = React.useState(
|
||||
closetList.description
|
||||
);
|
||||
const hasChanges =
|
||||
editableName !== closetList.name ||
|
||||
editableDescription !== closetList.description;
|
||||
const onSaveChanges = () => {
|
||||
if (!hasChanges) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
sendSaveChangesMutation({
|
||||
variables: {
|
||||
closetListId: closetList.id,
|
||||
name: editableName,
|
||||
description: editableDescription,
|
||||
supportSecret,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
setIsEditing(false);
|
||||
toast({
|
||||
status: "success",
|
||||
title: "Changes saved!",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Sorry, we couldn't save this list 😖",
|
||||
description: "Check your connection and try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box id={anchorId}>
|
||||
<Flex align="center" wrap="wrap" marginBottom="2">
|
||||
{showHeading &&
|
||||
(isEditing ? (
|
||||
<Heading3
|
||||
as={Input}
|
||||
value={editableName}
|
||||
onChange={(e) => setEditableName(e.target.value)}
|
||||
maxWidth="20ch"
|
||||
// Shift left by our own padding/border, for alignment with the
|
||||
// original title
|
||||
paddingX="0.75rem"
|
||||
marginLeft="calc(-0.75rem - 1px)"
|
||||
boxShadow="sm"
|
||||
/>
|
||||
) : (
|
||||
<Heading3
|
||||
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
|
||||
lineHeight="1.2" // to match Input
|
||||
paddingY="2px" // to account for Input border/padding
|
||||
>
|
||||
{closetList.isDefaultList ? (
|
||||
closetList.name
|
||||
) : (
|
||||
<Box
|
||||
as={Link}
|
||||
to={buildClosetListPath(closetList)}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
{closetList.name}
|
||||
</Box>
|
||||
)}
|
||||
</Heading3>
|
||||
))}
|
||||
<Box flex="1 0 auto" width="4" />
|
||||
{(isCurrentUser || isSupportUser) &&
|
||||
!closetList.isDefaultList &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<WIPCallout
|
||||
size="sm"
|
||||
details="To edit the items, head back to Classic DTI!"
|
||||
marginY="2"
|
||||
>
|
||||
WIP: Can only edit text for now!
|
||||
</WIPCallout>
|
||||
<Box width="4" />
|
||||
<HStack spacing="2" marginLeft="auto" marginY="1">
|
||||
<Button size="sm" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
display="flex"
|
||||
align="center"
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
onClick={onSaveChanges}
|
||||
isLoading={loadingSaveChanges}
|
||||
>
|
||||
<CheckIcon marginRight="1" />
|
||||
Save changes
|
||||
</Button>
|
||||
</HStack>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
display="flex"
|
||||
align="center"
|
||||
size="sm"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<EditIcon marginRight="1" />
|
||||
Edit
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
{closetList.description && (
|
||||
<Box marginBottom="2">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={editableDescription}
|
||||
onChange={(e) => setEditableDescription(e.target.value)}
|
||||
// Shift left by our own padding/border, for alignment with the
|
||||
// original title
|
||||
paddingX="0.75rem"
|
||||
marginLeft="calc(-0.75rem - 1px)"
|
||||
boxShadow="sm"
|
||||
/>
|
||||
) : (
|
||||
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<ClosetListContents
|
||||
closetList={closetList}
|
||||
isCurrentUser={isCurrentUser}
|
||||
// For default lists, we don't have a separate page, we just inline
|
||||
// them all here. This is a less-nice experience, but it simplifies
|
||||
// the single-list page a lot to not have to care, and for now we just
|
||||
// kinda expect that people who care about trade lists enough will
|
||||
// group them into lists so it's nbd! ^_^`
|
||||
maxNumItemsToShow={!closetList.isDefaultList ? 14 : null}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSupportMenu({ children, user }) {
|
||||
const { supportSecret } = useSupport();
|
||||
const toast = useToast();
|
||||
|
|
Loading…
Reference in a new issue