diff --git a/scripts/setup-mysql.sql b/scripts/setup-mysql.sql index 90c61e3..a6a4a21 100644 --- a/scripts/setup-mysql.sql +++ b/scripts/setup-mysql.sql @@ -29,7 +29,7 @@ GRANT SELECT, INSERT, DELETE ON closet_hangers TO impress2020; GRANT SELECT, UPDATE ON closet_lists TO impress2020; GRANT SELECT, DELETE ON item_outfit_relationships 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 openneo_id.users TO impress2020; diff --git a/src/app/WardrobePage/ItemsPanel.js b/src/app/WardrobePage/ItemsPanel.js index dcc955d..4e984e1 100644 --- a/src/app/WardrobePage/ItemsPanel.js +++ b/src/app/WardrobePage/ItemsPanel.js @@ -18,21 +18,38 @@ import { Button, Spinner, useColorModeValue, + Modal, + ModalContent, + ModalOverlay, + ModalHeader, + ModalBody, + ModalFooter, + useDisclosure, + ModalCloseButton, } from "@chakra-ui/react"; import { CheckIcon, + DeleteIcon, EditIcon, QuestionIcon, WarningTwoIcon, } from "@chakra-ui/icons"; 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 { BiRename } from "react-icons/bi"; import { IoCloudUploadOutline } from "react-icons/io5"; import { MdMoreVert } from "react-icons/md"; import { buildOutfitUrl } from "./useOutfitState"; +import { gql, useMutation } from "@apollo/client"; /** * 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. */ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { + const { canDeleteOutfit } = outfitSaving; const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true }); return ( @@ -422,6 +440,9 @@ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { > Rename + {canDeleteOutfit && ( + + )} @@ -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 ( + <> + } onClick={onOpen}> + Delete + + + + + Delete outfit "{name}"? + + + 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 && ( + + Error deleting outfit: "{getGraphQLErrorMessage(error)}". Try + again? + + )} + + + + + + + + + + ); +} + /** * fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the * fade-out and height decrease when an Item or ItemZoneGroup is removed. diff --git a/src/app/WardrobePage/useOutfitSaving.js b/src/app/WardrobePage/useOutfitSaving.js index abd4c4f..58d4265 100644 --- a/src/app/WardrobePage/useOutfitSaving.js +++ b/src/app/WardrobePage/useOutfitSaving.js @@ -46,6 +46,11 @@ function useOutfitSaving(outfitState, dispatchToOutfit) { const canSaveOutfit = 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( gql` mutation UseOutfitSaving_SaveOutfit( @@ -232,6 +237,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) { return { canSaveOutfit, + canDeleteOutfit, isNewOutfit, isSaving, latestVersionIsSaved, diff --git a/src/app/util.js b/src/app/util.js index d875538..48af3e0 100644 --- a/src/app/util.js +++ b/src/app/util.js @@ -426,12 +426,15 @@ export function logAndCapture(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 // error the server returned. Otherwise, just use the normal error message! - const message = - error?.networkError?.result?.errors?.[0]?.message || error?.message || null; + return ( + 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 // experience without the parent worrying about it! React.useEffect(() => { @@ -512,7 +515,7 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) { marginTop="-2px" aria-label="Error message" /> - "{message}" + "{getGraphQLErrorMessage(message)}" )} diff --git a/src/server/types/Outfit.js b/src/server/types/Outfit.js index 8533892..55f52ba 100644 --- a/src/server/types/Outfit.js +++ b/src/server/types/Outfit.js @@ -46,6 +46,13 @@ const typeDefs = gql` wornItemIds: [ID!]! closetedItemIds: [ID!]! ): 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 }; }, + + 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; + }, }, };