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;
+ },
},
};