Added category deletion interface
This commit is contained in:
@@ -2,11 +2,23 @@
|
|||||||
import type { Category } from "~/models/Category";
|
import type { Category } from "~/models/Category";
|
||||||
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
|
import { ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb } from "radix-vue"
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { categories } = defineProps<{
|
const { categories } = defineProps<{
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.localeCompare(b.name)))
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,6 +32,7 @@ const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.loc
|
|||||||
v-for="item in sortedCategories"
|
v-for="item in sortedCategories"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:category="item"
|
:category="item"
|
||||||
|
@on-delete-category="openDeleteDialog"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollAreaViewport>
|
</ScrollAreaViewport>
|
||||||
@@ -35,4 +48,25 @@ const sortedCategories = computed(() => categories.toSorted((a, b) => a.name.loc
|
|||||||
|
|
||||||
<CalendarCategoryTableFooter />
|
<CalendarCategoryTableFooter />
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PhCheck } from "@phosphor-icons/vue"
|
import { PhCheck, PhTrash } from "@phosphor-icons/vue"
|
||||||
import { useToast } from "~/components/ui/toast"
|
import { useToast } from "~/components/ui/toast"
|
||||||
import { ToastLifetime } from "~/components/ui/toast/use-toast"
|
import { ToastLifetime } from "~/components/ui/toast/use-toast"
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
@@ -12,12 +12,17 @@ const { category } = defineProps<{
|
|||||||
category: Category
|
category: Category
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "on-delete-category", payload: Category): void
|
||||||
|
}>()
|
||||||
|
|
||||||
type RowMode = "edit" | "view"
|
type RowMode = "edit" | "view"
|
||||||
const currentMode = ref<RowMode>("view")
|
const currentMode = ref<RowMode>("view")
|
||||||
|
|
||||||
const rowRef = ref<HTMLDivElement | null>(null)
|
const rowRef = ref<HTMLDivElement | null>(null)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
const { focused: inputFocused } = useFocus(inputRef)
|
const { focused: inputFocused } = useFocus(inputRef)
|
||||||
|
const rowHovered = useElementHover(rowRef)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle view mode options
|
* Toggle view mode options
|
||||||
@@ -86,6 +91,11 @@ async function submitUpdate() {
|
|||||||
duration: ToastLifetime.SHORT
|
duration: ToastLifetime.SHORT
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleQueryDelete() {
|
||||||
|
categorySkeleton.value = structuredClone(toRaw(category))
|
||||||
|
emit("on-delete-category", category)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -148,19 +158,30 @@ async function submitUpdate() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<menu class="w-fit absolute top-1/2 -translate-y-1/2 right-2 flex items-center gap-2">
|
<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
|
<UiButton
|
||||||
v-if="currentMode === 'edit' && categorySkeleton"
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="w-6 h-6 rounded-full bg-emerald-500 hover:bg-emerald-600 text-white"
|
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')"
|
:title="$t('ui.actions.edit')"
|
||||||
@click="submitUpdate"
|
@click="submitUpdate"
|
||||||
>
|
>
|
||||||
<PhCheck size="14" weight="bold" />
|
<PhCheck size="14" weight="bold" />
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</li>
|
</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>
|
</menu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { CalendarFormCategories } from "#components";
|
|
||||||
import { PhX } from "@phosphor-icons/vue";
|
import { PhX } from "@phosphor-icons/vue";
|
||||||
|
|
||||||
const { toggleCategoriesModal } = useCalendar()
|
const { toggleCategoriesModal } = useCalendar()
|
||||||
const { isCategoriesModalOpen } = storeToRefs(useCalendar())
|
const { categories, isCategoriesModalOpen } = storeToRefs(useCalendar())
|
||||||
|
|
||||||
function handleClosing() {
|
function handleClosing() {
|
||||||
toggleCategoriesModal(false)
|
toggleCategoriesModal(false)
|
||||||
@@ -36,7 +35,7 @@ function handleClosing() {
|
|||||||
<PhX size="20" />
|
<PhX size="20" />
|
||||||
</UiButton>
|
</UiButton>
|
||||||
|
|
||||||
<CalendarFormCategories />
|
<CalendarCategoryTable :categories />
|
||||||
</UiAlertDialogContent>
|
</UiAlertDialogContent>
|
||||||
</UiAlertDialog>
|
</UiAlertDialog>
|
||||||
</template>
|
</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",
|
title: "Manage categories",
|
||||||
subtitle: "Add and change the categories of your calendar",
|
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: {
|
addedToast: {
|
||||||
title: "The category \"{category}\" has been added to the calendar.",
|
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.",
|
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",
|
title: "Gestion des catégories",
|
||||||
subtitle: "Créer et modifier les catégories de votre calendrier",
|
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: {
|
addedToast: {
|
||||||
title: "La catégorie \"{category}\" a été ajoutée au calendrier.",
|
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.",
|
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()
|
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 {
|
return {
|
||||||
isCreatingCategory,
|
isCreatingCategory,
|
||||||
isUpdatingCategory,
|
isUpdatingCategory,
|
||||||
@@ -81,6 +110,8 @@ export const useCategoryStore = defineStore("calendar-category", () => {
|
|||||||
categorySkeleton,
|
categorySkeleton,
|
||||||
resetSkeleton,
|
resetSkeleton,
|
||||||
addCategoryFromSkeleton,
|
addCategoryFromSkeleton,
|
||||||
updateCategoryFromSkeleton
|
updateCategoryFromSkeleton,
|
||||||
|
deleteCategoryFromSkeleton,
|
||||||
|
cancelLatestRequest
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ create table public.calendar_events (
|
|||||||
location text,
|
location text,
|
||||||
start_date json not null,
|
start_date json not null,
|
||||||
end_date json,
|
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,
|
hidden boolean default false,
|
||||||
wiki text,
|
wiki text,
|
||||||
created_at timestamptz default now(),
|
created_at timestamptz default now(),
|
||||||
@@ -150,7 +150,7 @@ create table public.characters (
|
|||||||
description text,
|
description text,
|
||||||
birth json not null,
|
birth json not null,
|
||||||
death json,
|
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_birth boolean,
|
||||||
hidden_death boolean,
|
hidden_death boolean,
|
||||||
wiki text,
|
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
|
-- Send "previous data" on change
|
||||||
alter table public.users replica identity full;
|
alter table public.users replica identity full;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user