Add Delete button for outfits

Hey finally! I got in the mood and did it, after… a year? idk lol

The button should only appear for outfits that are already saved, that are owned by you. And the server enforces it!

I also added a new util function to give actually useful error messages when the GraphQL server throws an error. Might be wise to use this in more places where we're currently just using `error.message`!
This commit is contained in:
Emi Matchu 2022-08-15 20:23:17 -07:00
parent 5dfd67a221
commit 25092b865a
5 changed files with 134 additions and 6 deletions

View file

@ -29,7 +29,7 @@ GRANT SELECT, INSERT, DELETE ON closet_hangers TO impress2020;
GRANT SELECT, UPDATE ON closet_lists TO impress2020; GRANT SELECT, UPDATE ON closet_lists TO impress2020;
GRANT SELECT, DELETE ON item_outfit_relationships TO impress2020; GRANT SELECT, DELETE ON item_outfit_relationships TO impress2020;
GRANT SELECT ON neopets_connections TO impress2020; GRANT SELECT ON neopets_connections TO impress2020;
GRANT SELECT, INSERT, UPDATE ON outfits TO impress2020; GRANT SELECT, INSERT, UPDATE, DELETE ON outfits TO impress2020;
GRANT SELECT, UPDATE ON users TO impress2020; GRANT SELECT, UPDATE ON users TO impress2020;
GRANT SELECT, UPDATE ON openneo_id.users TO impress2020; GRANT SELECT, UPDATE ON openneo_id.users TO impress2020;

View file

@ -18,21 +18,38 @@ import {
Button, Button,
Spinner, Spinner,
useColorModeValue, useColorModeValue,
Modal,
ModalContent,
ModalOverlay,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
ModalCloseButton,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
CheckIcon, CheckIcon,
DeleteIcon,
EditIcon, EditIcon,
QuestionIcon, QuestionIcon,
WarningTwoIcon, WarningTwoIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group"; import { CSSTransition, TransitionGroup } from "react-transition-group";
import { useHistory } from "react-router-dom";
import { Delay, Heading1, Heading2 } from "../util"; import {
Delay,
ErrorMessage,
getGraphQLErrorMessage,
Heading1,
Heading2,
} from "../util";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
import { BiRename } from "react-icons/bi"; import { BiRename } from "react-icons/bi";
import { IoCloudUploadOutline } from "react-icons/io5"; import { IoCloudUploadOutline } from "react-icons/io5";
import { MdMoreVert } from "react-icons/md"; import { MdMoreVert } from "react-icons/md";
import { buildOutfitUrl } from "./useOutfitState"; import { buildOutfitUrl } from "./useOutfitState";
import { gql, useMutation } from "@apollo/client";
/** /**
* ItemsPanel shows the items in the current outfit, and lets the user toggle * ItemsPanel shows the items in the current outfit, and lets the user toggle
@ -360,6 +377,7 @@ function OutfitSavingIndicator({ outfitSaving }) {
* It also contains the outfit menu, for saving etc. * It also contains the outfit menu, for saving etc.
*/ */
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
const { canDeleteOutfit } = outfitSaving;
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true }); const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
return ( return (
@ -422,6 +440,9 @@ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
> >
Rename Rename
</MenuItem> </MenuItem>
{canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} />
)}
</MenuList> </MenuList>
</Portal> </Portal>
</Menu> </Menu>
@ -431,6 +452,77 @@ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
); );
} }
function DeleteOutfitMenuItem({ outfitState }) {
const { id, name } = outfitState;
const { isOpen, onOpen, onClose } = useDisclosure();
const history = useHistory();
const [sendDeleteOutfitMutation, { loading, error }] = useMutation(
gql`
mutation DeleteOutfitMenuItem($id: ID!) {
deleteOutfit(id: $id)
}
`,
{
context: { sendAuth: true },
update(cache) {
// Once this is deleted, evict it from the local cache, and "garbage
// collect" to force all queries referencing this outfit to reload the
// next time we see them. (This is especially important since we're
// about to redirect to the user outfits page, which shouldn't show
// the outfit anymore!)
cache.evict(`Outfit:${id}`);
cache.gc();
},
}
);
return (
<>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete
</MenuItem>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton />
<ModalBody>
We'll delete this data and remove it from your list of outfits.
Links and image embeds pointing to this outfit will break. Is that
okay?
{error && (
<ErrorMessage marginTop="1em">
Error deleting outfit: "{getGraphQLErrorMessage(error)}". Try
again?
</ErrorMessage>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" />
<Button
colorScheme="red"
onClick={() =>
sendDeleteOutfitMutation({ variables: { id } })
.then(() => {
history.push(`/your-outfits`);
})
.catch((e) => {
/* handled in error UI */
})
}
isLoading={loading}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
/** /**
* fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the * fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the
* fade-out and height decrease when an Item or ItemZoneGroup is removed. * fade-out and height decrease when an Item or ItemZoneGroup is removed.

View file

@ -46,6 +46,11 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
const canSaveOutfit = const canSaveOutfit =
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId); isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
// Users can delete their own outfits too. The logic is slightly different
// than for saving, because you can save an outfit that hasn't been saved
// yet, but you can't delete it.
const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation( const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation(
gql` gql`
mutation UseOutfitSaving_SaveOutfit( mutation UseOutfitSaving_SaveOutfit(
@ -232,6 +237,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
return { return {
canSaveOutfit, canSaveOutfit,
canDeleteOutfit,
isNewOutfit, isNewOutfit,
isSaving, isSaving,
latestVersionIsSaved, latestVersionIsSaved,

View file

@ -426,12 +426,15 @@ export function logAndCapture(e) {
Sentry.captureException(e); Sentry.captureException(e);
} }
export function MajorErrorMessage({ error = null, variant = "unexpected" }) { export function getGraphQLErrorMessage(error) {
// If this is a GraphQL Bad Request error, show the message of the first // If this is a GraphQL Bad Request error, show the message of the first
// error the server returned. Otherwise, just use the normal error message! // error the server returned. Otherwise, just use the normal error message!
const message = return (
error?.networkError?.result?.errors?.[0]?.message || error?.message || null; error?.networkError?.result?.errors?.[0]?.message || error?.message || null
);
}
export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
// Log the detailed error to the console, so we can have a good debug // Log the detailed error to the console, so we can have a good debug
// experience without the parent worrying about it! // experience without the parent worrying about it!
React.useEffect(() => { React.useEffect(() => {
@ -512,7 +515,7 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
marginTop="-2px" marginTop="-2px"
aria-label="Error message" aria-label="Error message"
/> />
"{message}" "{getGraphQLErrorMessage(message)}"
</Box> </Box>
)} )}
</Grid> </Grid>

View file

@ -46,6 +46,13 @@ const typeDefs = gql`
wornItemIds: [ID!]! wornItemIds: [ID!]!
closetedItemIds: [ID!]! closetedItemIds: [ID!]!
): Outfit! ): Outfit!
"""
Delete the outfit with the given ID.
Only the user who owns it has permission to do this.
Returns the ID of the deleted outfit. (A bit silly but that's that!)
"""
deleteOutfit(id: ID!): ID!
} }
`; `;
@ -295,6 +302,26 @@ const resolvers = {
return { id: newOutfitId }; return { id: newOutfitId };
}, },
deleteOutfit: async (_, { id }, { currentUserId, db, outfitLoader }) => {
if (!currentUserId) {
throw new Error("Must be logged in to delete outfits.");
}
const outfit = await outfitLoader.load(id);
if (outfit == null) {
throw new Error(`outfit ${outfit.id} does not exist`);
}
if (outfit.userId !== currentUserId) {
throw new Error(
`user ${currentUserId} does not own outfit ${outfit.id}`
);
}
await db.query(`DELETE FROM outfits WHERE id = ? LIMIT 1;`, [id]);
return id;
},
}, },
}; };