Can edit closet list text, incl as Support

I wanted the ability to clear out closet list text for Support users, and figured I should just build the UI for end users too, and grant Support users the same access!
This commit is contained in:
Emi Matchu 2021-03-23 17:48:11 -07:00
parent 790c231b5d
commit 62952b80dd
4 changed files with 246 additions and 19 deletions

View file

@ -26,7 +26,7 @@ GRANT INSERT ON modeling_logs TO impress2020;
-- User data tables
GRANT SELECT, INSERT, DELETE ON closet_hangers TO impress2020;
GRANT SELECT ON closet_lists TO impress2020;
GRANT SELECT, UPDATE ON closet_lists TO impress2020;
GRANT SELECT ON item_outfit_relationships TO impress2020;
GRANT SELECT ON neopets_connections TO impress2020;
GRANT SELECT ON outfits TO impress2020;

View file

@ -19,6 +19,9 @@ import {
WrapItem,
VStack,
useToast,
Button,
Textarea,
HStack,
} from "@chakra-ui/react";
import {
ArrowForwardIcon,
@ -239,11 +242,6 @@ function UserItemsPage() {
</Flex>
<Box marginTop="4">
{isCurrentUser && (
<Box float="right">
<WIPCallout details="These lists are read-only for now. To edit, head back to Classic DTI!" />
</Box>
)}
<Heading2 id="owned-items" marginBottom="2">
{isCurrentUser
? "Items you own"
@ -417,6 +415,9 @@ function UserSearchForm() {
}
function ClosetList({ closetList, isCurrentUser, showHeading }) {
const { isSupportUser, supportSecret } = useSupport();
const toast = useToast();
const hasYouWantThisBadge = (item) =>
!isCurrentUser &&
closetList.ownsOrWantsItems === "OWNS" &&
@ -446,19 +447,155 @@ function ClosetList({ closetList, isCurrentUser, showHeading }) {
}
}, [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}>
{showHeading && (
<Heading3
marginBottom="2"
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
>
{closetList.name}
</Heading3>
)}
<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.name}
</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">
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
{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>
)}
{sortedItems.length > 0 ? (

View file

@ -6,7 +6,13 @@ import { useCommonStyles } from "../util";
import WIPXweeImg from "../images/wip-xwee.png";
import WIPXweeImg2x from "../images/wip-xwee@2x.png";
function WIPCallout({ children, details, placement = "bottom", ...props }) {
function WIPCallout({
children,
details,
size = "md",
placement = "bottom",
...props
}) {
const { brightBackground } = useCommonStyles();
let content = (
@ -21,7 +27,7 @@ function WIPCallout({ children, details, placement = "bottom", ...props }) {
paddingLeft="2"
paddingRight="4"
paddingY="1"
fontSize="sm"
fontSize={size === "sm" ? "xs" : "sm"}
{...props}
>
<Box
@ -29,8 +35,8 @@ function WIPCallout({ children, details, placement = "bottom", ...props }) {
src={WIPXweeImg}
srcSet={`${WIPXweeImg} 1x, ${WIPXweeImg2x} 2x`}
alt=""
width="36px"
height="36px"
width={size === "sm" ? "24px" : "36px"}
height={size === "sm" ? "24px" : "36px"}
marginRight="2"
/>
{children || (

View file

@ -1,5 +1,7 @@
import { gql } from "apollo-server";
import { logToDiscord } from "../util";
const typeDefs = gql`
enum OwnsOrWants {
OWNS
@ -26,6 +28,17 @@ const typeDefs = gql`
items: [Item!]!
}
extend type Mutation {
# Edit the metadata of a closet list. Requires the current user to own the
# list, or for the correct supportSecret to be provided.
editClosetList(
closetListId: ID!
name: String!
description: String!
supportSecret: String
): ClosetList
}
`;
const resolvers = {
@ -91,6 +104,77 @@ const resolvers = {
);
},
},
Mutation: {
editClosetList: async (
_,
{ closetListId, name, description, supportSecret },
{ currentUserId, closetListLoader, userLoader, db }
) => {
const oldClosetList = await closetListLoader.load(closetListId);
if (!oldClosetList) {
console.warn(
`Skipping editClosetList for unknown closet list ID: ${closetListId}`
);
return null;
}
const isCurrentUser = oldClosetList.userId === currentUserId;
const isSupportUser = supportSecret === process.env["SUPPORT_SECRET"];
if (!isCurrentUser && !isSupportUser) {
throw new Error(
`Current user does not have permission to edit closet list ${closetListId}`
);
}
await db.execute(
`
UPDATE closet_lists SET name = ?, description = ? WHERE id = ? LIMIT 1
`,
[name, description, closetListId]
);
// we changed it, so clear it from cache
closetListLoader.clear(closetListId);
// If this was a Support action (rather than a normal edit), log it.
if (!isCurrentUser && isSupportUser) {
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
try {
const user = await userLoader.load(oldClosetList.userId);
await logToDiscord({
embeds: [
{
title: `🛠 User ${user.name} - List ${closetListId}`,
fields: [
{
name: `Name`,
value: `${oldClosetList.name} → **${name}**`,
},
{
name: `Description`,
value:
`\`${oldClosetList.description.substr(0, 60)}\`` +
`→ **\`${description.substr(0, 60)}\`**`,
},
],
timestamp: new Date().toISOString(),
url: `https://impress-2020.openneo.net/user/${user.id}/items#list-${closetListId}`,
},
],
});
} catch (e) {
console.error("Error sending Discord support log", e);
}
} else {
console.warn("No Discord support webhook provided, skipping");
}
}
return { id: closetListId };
},
},
};
module.exports = { typeDefs, resolvers };