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:
parent
790c231b5d
commit
62952b80dd
4 changed files with 246 additions and 19 deletions
|
@ -26,7 +26,7 @@ GRANT INSERT ON modeling_logs TO impress2020;
|
||||||
|
|
||||||
-- User data tables
|
-- User data tables
|
||||||
GRANT SELECT, INSERT, DELETE ON closet_hangers TO impress2020;
|
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 item_outfit_relationships TO impress2020;
|
||||||
GRANT SELECT ON neopets_connections TO impress2020;
|
GRANT SELECT ON neopets_connections TO impress2020;
|
||||||
GRANT SELECT ON outfits TO impress2020;
|
GRANT SELECT ON outfits TO impress2020;
|
||||||
|
|
|
@ -19,6 +19,9 @@ import {
|
||||||
WrapItem,
|
WrapItem,
|
||||||
VStack,
|
VStack,
|
||||||
useToast,
|
useToast,
|
||||||
|
Button,
|
||||||
|
Textarea,
|
||||||
|
HStack,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import {
|
import {
|
||||||
ArrowForwardIcon,
|
ArrowForwardIcon,
|
||||||
|
@ -239,11 +242,6 @@ function UserItemsPage() {
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Box marginTop="4">
|
<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">
|
<Heading2 id="owned-items" marginBottom="2">
|
||||||
{isCurrentUser
|
{isCurrentUser
|
||||||
? "Items you own"
|
? "Items you own"
|
||||||
|
@ -417,6 +415,9 @@ function UserSearchForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClosetList({ closetList, isCurrentUser, showHeading }) {
|
function ClosetList({ closetList, isCurrentUser, showHeading }) {
|
||||||
|
const { isSupportUser, supportSecret } = useSupport();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const hasYouWantThisBadge = (item) =>
|
const hasYouWantThisBadge = (item) =>
|
||||||
!isCurrentUser &&
|
!isCurrentUser &&
|
||||||
closetList.ownsOrWantsItems === "OWNS" &&
|
closetList.ownsOrWantsItems === "OWNS" &&
|
||||||
|
@ -446,19 +447,155 @@ function ClosetList({ closetList, isCurrentUser, showHeading }) {
|
||||||
}
|
}
|
||||||
}, [anchorId]);
|
}, [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 (
|
return (
|
||||||
<Box id={anchorId}>
|
<Box id={anchorId}>
|
||||||
{showHeading && (
|
<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
|
<Heading3
|
||||||
marginBottom="2"
|
|
||||||
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
|
fontStyle={closetList.isDefaultList ? "italic" : "normal"}
|
||||||
|
lineHeight="1.2" // to match Input
|
||||||
|
paddingY="2px" // to account for Input border/padding
|
||||||
>
|
>
|
||||||
{closetList.name}
|
{closetList.name}
|
||||||
</Heading3>
|
</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 && (
|
{closetList.description && (
|
||||||
<Box marginBottom="2">
|
<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>
|
<MarkdownAndSafeHTML>{closetList.description}</MarkdownAndSafeHTML>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{sortedItems.length > 0 ? (
|
{sortedItems.length > 0 ? (
|
||||||
|
|
|
@ -6,7 +6,13 @@ import { useCommonStyles } from "../util";
|
||||||
import WIPXweeImg from "../images/wip-xwee.png";
|
import WIPXweeImg from "../images/wip-xwee.png";
|
||||||
import WIPXweeImg2x from "../images/wip-xwee@2x.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();
|
const { brightBackground } = useCommonStyles();
|
||||||
|
|
||||||
let content = (
|
let content = (
|
||||||
|
@ -21,7 +27,7 @@ function WIPCallout({ children, details, placement = "bottom", ...props }) {
|
||||||
paddingLeft="2"
|
paddingLeft="2"
|
||||||
paddingRight="4"
|
paddingRight="4"
|
||||||
paddingY="1"
|
paddingY="1"
|
||||||
fontSize="sm"
|
fontSize={size === "sm" ? "xs" : "sm"}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
@ -29,8 +35,8 @@ function WIPCallout({ children, details, placement = "bottom", ...props }) {
|
||||||
src={WIPXweeImg}
|
src={WIPXweeImg}
|
||||||
srcSet={`${WIPXweeImg} 1x, ${WIPXweeImg2x} 2x`}
|
srcSet={`${WIPXweeImg} 1x, ${WIPXweeImg2x} 2x`}
|
||||||
alt=""
|
alt=""
|
||||||
width="36px"
|
width={size === "sm" ? "24px" : "36px"}
|
||||||
height="36px"
|
height={size === "sm" ? "24px" : "36px"}
|
||||||
marginRight="2"
|
marginRight="2"
|
||||||
/>
|
/>
|
||||||
{children || (
|
{children || (
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { gql } from "apollo-server";
|
import { gql } from "apollo-server";
|
||||||
|
|
||||||
|
import { logToDiscord } from "../util";
|
||||||
|
|
||||||
const typeDefs = gql`
|
const typeDefs = gql`
|
||||||
enum OwnsOrWants {
|
enum OwnsOrWants {
|
||||||
OWNS
|
OWNS
|
||||||
|
@ -26,6 +28,17 @@ const typeDefs = gql`
|
||||||
|
|
||||||
items: [Item!]!
|
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 = {
|
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 };
|
module.exports = { typeDefs, resolvers };
|
||||||
|
|
Loading…
Reference in a new issue