Added category deletion interface
This commit is contained in:
@@ -2,11 +2,23 @@
|
||||
import type { Category } from "~/models/Category";
|
||||
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { categories } = defineProps<{
|
||||
categories: Category[]
|
||||
}>()
|
||||
|
||||
const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.localeCompare(b.name)))
|
||||
|
||||
const deleteDialogOpened = ref<boolean>(false)
|
||||
|
||||
function openDeleteDialog() {
|
||||
deleteDialogOpened.value = true
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
deleteDialogOpened.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,6 +32,7 @@ const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.loc
|
||||
v-for="item in sortedCategories"
|
||||
:key="item.id"
|
||||
:category="item"
|
||||
@on-delete-category="openDeleteDialog"
|
||||
/>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
@@ -35,4 +48,25 @@ const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.loc
|
||||
|
||||
<CalendarCategoryTableFooter />
|
||||
</div>
|
||||
|
||||
<UiDialog v-model:open="deleteDialogOpened">
|
||||
<UiDialogContent
|
||||
:disable-outside-pointer-events="true"
|
||||
:trap-focus="true"
|
||||
class="min-w-96 border-indigo-200 dark:bg-slate-950 dark:border-indigo-950"
|
||||
@escape-key-down="closeDeleteDialog"
|
||||
@focus-outside="closeDeleteDialog"
|
||||
@interact-outside="closeDeleteDialog"
|
||||
@pointer-down-outside="closeDeleteDialog"
|
||||
>
|
||||
<UiDialogTitle>
|
||||
{{ $t('entity.category.deleteDialog.title') }}
|
||||
</UiDialogTitle>
|
||||
<UiDialogDescription>
|
||||
{{ $t('entity.category.deleteDialog.subtitle') }}
|
||||
</UiDialogDescription>
|
||||
|
||||
<CalendarFormDeleteCategory />
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { PhCheck } from "@phosphor-icons/vue"
|
||||
import { PhCheck, PhTrash } from "@phosphor-icons/vue"
|
||||
import { useToast } from "~/components/ui/toast"
|
||||
import { ToastLifetime } from "~/components/ui/toast/use-toast"
|
||||
import { cn } from "~/lib/utils"
|
||||
@@ -12,12 +12,17 @@ const { category } = defineProps<{
|
||||
category: Category
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "on-delete-category", payload: Category): void
|
||||
}>()
|
||||
|
||||
type RowMode = "edit" | "view"
|
||||
const currentMode = ref<RowMode>("view")
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const { focused: inputFocused } = useFocus(inputRef)
|
||||
const rowHovered = useElementHover(rowRef)
|
||||
|
||||
/**
|
||||
* Toggle view mode options
|
||||
@@ -86,6 +91,11 @@ async function submitUpdate() {
|
||||
duration: ToastLifetime.SHORT
|
||||
})
|
||||
}
|
||||
|
||||
function handleQueryDelete() {
|
||||
categorySkeleton.value = structuredClone(toRaw(category))
|
||||
emit("on-delete-category", category)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -148,19 +158,30 @@ async function submitUpdate() {
|
||||
</div>
|
||||
</form>
|
||||
<menu class="w-fit absolute top-1/2 -translate-y-1/2 right-2 flex items-center gap-2">
|
||||
<li>
|
||||
<li
|
||||
v-if="currentMode === 'edit' && categorySkeleton"
|
||||
>
|
||||
<UiButton
|
||||
v-if="currentMode === 'edit' && categorySkeleton"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="w-6 h-6 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white"
|
||||
:to="{ name: 'calendar.category.edit', params: { id: category.id } }"
|
||||
:title="$t('ui.actions.edit')"
|
||||
@click="submitUpdate"
|
||||
>
|
||||
<PhCheck size="14" weight="bold" />
|
||||
</UiButton>
|
||||
</li>
|
||||
<li v-else-if="rowHovered">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="w-6 h-6 rounded-full hover:bg-red-600 hover:text-white"
|
||||
:title="$t('ui.actions.delete')"
|
||||
@click="handleQueryDelete"
|
||||
>
|
||||
<PhTrash size="14" weight="bold" />
|
||||
</UiButton>
|
||||
</li>
|
||||
</menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarFormCategories } from "#components";
|
||||
import { PhX } from "@phosphor-icons/vue";
|
||||
|
||||
const { toggleCategoriesModal } = useCalendar()
|
||||
const { isCategoriesModalOpen } = storeToRefs(useCalendar())
|
||||
const { categories, isCategoriesModalOpen } = storeToRefs(useCalendar())
|
||||
|
||||
function handleClosing() {
|
||||
toggleCategoriesModal(false)
|
||||
@@ -36,7 +35,7 @@ function handleClosing() {
|
||||
<PhX size="20" />
|
||||
</UiButton>
|
||||
|
||||
<CalendarFormCategories />
|
||||
<CalendarCategoryTable :categories />
|
||||
</UiAlertDialogContent>
|
||||
</UiAlertDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const { categories } = storeToRefs(useCalendar())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCategoryTable :categories />
|
||||
</template>
|
||||
85
components/calendar/form/DeleteCategory.vue
Normal file
85
components/calendar/form/DeleteCategory.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhCircleNotch } from "@phosphor-icons/vue";
|
||||
import { useToast } from "~/components/ui/toast";
|
||||
import { ToastLifetime } from "~/components/ui/toast/use-toast";
|
||||
|
||||
const { deleteCategoryFromSkeleton, cancelLatestRequest } = useCategoryStore()
|
||||
const { isDeletingCategory, categorySkeleton } = storeToRefs(useCategoryStore())
|
||||
|
||||
const { toast } = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
const formErrors = reactive<{ message: string | null }>({
|
||||
message: null
|
||||
})
|
||||
|
||||
async function handleAction(): Promise<void> {
|
||||
if (isLoading.value && !categorySkeleton.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const categoryName = categorySkeleton.value?.name
|
||||
|
||||
try {
|
||||
await deleteCategoryFromSkeleton()
|
||||
|
||||
toast({
|
||||
title: t("entity.calendar.category.deletedToast.title", { category: categoryName }),
|
||||
variant: "success",
|
||||
duration: ToastLifetime.MEDIUM
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
formErrors.message = err.message
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the cancel button
|
||||
*
|
||||
* Must cancel the abortController in the store, and stop the loading
|
||||
*/
|
||||
function handleCancel(): void {
|
||||
cancelLatestRequest()
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleAction">
|
||||
<div class="grid grid-cols-2 gap-y-4">
|
||||
<div class="text-red-500 ml-8">
|
||||
<span class="text-sm">
|
||||
{{ formErrors.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="flex gap-2 justify-between">
|
||||
<UiButton type="button" size="sm" variant="outline" @click="() => isDeletingCategory = false">
|
||||
{{ $t('ui.action.back') }}
|
||||
</UiButton>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Transition name="fade-delay">
|
||||
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
|
||||
{{ $t('ui.action.cancel') }}
|
||||
</UiButton>
|
||||
</Transition>
|
||||
|
||||
<UiButton size="sm" variant="destructive" :disabled="isLoading">
|
||||
<Transition name="fade">
|
||||
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
|
||||
</Transition>
|
||||
|
||||
{{ $t('ui.action.delete') }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
@@ -98,6 +98,10 @@ export default defineI18nConfig(() => ({
|
||||
title: "Manage categories",
|
||||
subtitle: "Add and change the categories of your calendar",
|
||||
},
|
||||
deleteDialog: {
|
||||
title: "Delete this category ?",
|
||||
subtitle: "The events attached to this category won't be deleted, but you'll lose the category for this calendar.",
|
||||
},
|
||||
addedToast: {
|
||||
title: "The category \"{category}\" has been added to the calendar.",
|
||||
titleError: "An error has occured and the category \"{category}\" wasn't added to the calendar.",
|
||||
@@ -413,6 +417,10 @@ export default defineI18nConfig(() => ({
|
||||
title: "Gestion des catégories",
|
||||
subtitle: "Créer et modifier les catégories de votre calendrier",
|
||||
},
|
||||
deleteDialog: {
|
||||
title: "Supprimer cette catégorie ?",
|
||||
subtitle: "Les évènements l'utilisant ne seront pas supprimés.",
|
||||
},
|
||||
addedToast: {
|
||||
title: "La catégorie \"{category}\" a été ajoutée au calendrier.",
|
||||
titleError: "Une erreur s'est produite et la catégorie \"{category}\" n'a pas pu être ajoutée.",
|
||||
|
||||
43
server/api/calendars/categories/[id].delete.ts
Normal file
43
server/api/calendars/categories/[id].delete.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod"
|
||||
import { serverSupabaseClient } from "#supabase/server"
|
||||
import type { Category } from "~/models/Category"
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.number({ coerce: true }).positive().int()
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const client = await serverSupabaseClient(event)
|
||||
|
||||
const { data: params, error: paramsError} = await getValidatedRouterParams(event, paramsSchema.safeParse)
|
||||
|
||||
if (paramsError) {
|
||||
throw createError({
|
||||
cause: "Utilisateur",
|
||||
fatal: false,
|
||||
message: "L'identifiant de l'évènement est manquant ou mal renseigné.",
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.from("calendar_event_categories")
|
||||
.delete()
|
||||
.eq("id", params.id)
|
||||
.maybeSingle<Category>()
|
||||
|
||||
console.log(error)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
throw createError({
|
||||
cause: "Serveur",
|
||||
status: 500,
|
||||
fatal: false,
|
||||
message: "Une erreur inconnue est survenue."
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -73,6 +73,35 @@ export const useCategoryStore = defineStore("calendar-category", () => {
|
||||
resetSkeleton()
|
||||
}
|
||||
|
||||
async function deleteCategoryFromSkeleton() {
|
||||
if (!categorySkeleton.value) return
|
||||
|
||||
abortController = new AbortController()
|
||||
isDeletingCategory.value = true
|
||||
|
||||
const { error } = await tryCatch(
|
||||
$fetch<Category>(`/api/calendars/categories/${categorySkeleton.value.id}`, { method: "DELETE", signal: abortController.signal })
|
||||
)
|
||||
|
||||
if (error) {
|
||||
isDeletingCategory.value = false
|
||||
throw error
|
||||
}
|
||||
|
||||
const categoryIndex = categories.value.findIndex(c => c.id === categorySkeleton.value!.id)
|
||||
categories.value.splice(categoryIndex, 1)
|
||||
|
||||
abortController = null
|
||||
isDeletingCategory.value = false
|
||||
resetSkeleton()
|
||||
}
|
||||
|
||||
function cancelLatestRequest() {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isCreatingCategory,
|
||||
isUpdatingCategory,
|
||||
@@ -81,6 +110,8 @@ export const useCategoryStore = defineStore("calendar-category", () => {
|
||||
categorySkeleton,
|
||||
resetSkeleton,
|
||||
addCategoryFromSkeleton,
|
||||
updateCategoryFromSkeleton
|
||||
updateCategoryFromSkeleton,
|
||||
deleteCategoryFromSkeleton,
|
||||
cancelLatestRequest
|
||||
}
|
||||
})
|
||||
|
||||
@@ -106,7 +106,7 @@ create table public.calendar_events (
|
||||
location text,
|
||||
start_date json not null,
|
||||
end_date json,
|
||||
category bigint references public.calendar_event_categories on delete cascade,
|
||||
category bigint references public.calendar_event_categories on delete set null,
|
||||
hidden boolean default false,
|
||||
wiki text,
|
||||
created_at timestamptz default now(),
|
||||
@@ -150,7 +150,7 @@ create table public.characters (
|
||||
description text,
|
||||
birth json not null,
|
||||
death json,
|
||||
category bigint references public.character_categories on delete cascade,
|
||||
category bigint references public.character_categories on delete set null,
|
||||
hidden_birth boolean,
|
||||
hidden_death boolean,
|
||||
wiki text,
|
||||
@@ -468,6 +468,20 @@ create policy "Allow GMs to update their events categories"
|
||||
)
|
||||
);
|
||||
|
||||
create policy "Allow GMs to delete their events categories"
|
||||
on public.calendar_event_categories
|
||||
for delete
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.calendars c
|
||||
join public.worlds w on w.id = c.world_id
|
||||
where
|
||||
c.id = calendar_event_categories.calendar_id
|
||||
and w.gm_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Send "previous data" on change
|
||||
alter table public.users replica identity full;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user