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:
Emi Matchu 2021-06-19 09:44:44 -07:00
parent faf8364aab
commit d91492ab66
2 changed files with 250 additions and 233 deletions

View file

@ -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,8 +121,185 @@ function UserItemListPage() {
</BreadcrumbItem>
</Breadcrumb>
<Box height="1" />
<Heading1>{closetList.name}</Heading1>
<Wrap spacing="2" opacity="0.7">
<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
>
{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"
align="center"
size="sm"
onClick={() => setIsEditing(true)}
>
<EditIcon marginRight="1" />
Edit
</Button>
))}
</Flex>
{headingVariant === "top-level" && (
<Wrap spacing="2" opacity="0.7" marginBottom="2">
{closetList.creator?.contactNeopetsUsername && (
<WrapItem>
<Badge
@ -141,13 +327,34 @@ function UserItemListPage() {
</WrapItem>
)}
</Wrap>
<Box height="6" />
)}
<Box height="2" />
{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>
);

View file

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