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:
parent
5dfd67a221
commit
25092b865a
5 changed files with 134 additions and 6 deletions
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue