Merge pull request #19 from AlexisNP/bugfix/event-operations-should-have-a-loading

Bugfix/event operations should have a loading
This commit is contained in:
AlexisNP
2024-06-08 22:31:46 +02:00
committed by GitHub
8 changed files with 254 additions and 71 deletions

View File

@@ -74,3 +74,20 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
.fade-enter-active,
.fade-leave-active {
transition: all .5s ease;
}
.fade-delay-enter-active,
.fade-delay-leave-active {
transition: all .5s ease 1s;
}
.fade-enter-from,
.fade-leave-to,
.fade-delay-enter-from,
.fade-delay-leave-to {
opacity: 0;
visibility: hidden;
}

View File

@@ -93,7 +93,7 @@ onMounted(() => {
setCurrentMenu([ setCurrentMenu([
{ {
phIcon: PhMagnifyingGlass, phIcon: shallowRef(PhMagnifyingGlass),
tooltip: 'Recherche avancée', tooltip: 'Recherche avancée',
action: 'event-search' action: 'event-search'
} }
@@ -116,10 +116,10 @@ onMounted(() => {
<component :is="currentViewComponent"/> <component :is="currentViewComponent"/>
</KeepAlive> </KeepAlive>
</div> </div>
</template>
<LazyCalendarSearch /> <LazyCalendarSearch />
<LazyCalendarFormUpdateEvent /> <LazyCalendarFormUpdateEvent />
<LazyCalendarFormDeleteEvent /> <LazyCalendarFormDeleteEvent />
</template>
</div> </div>
</template> </template>

View File

@@ -1,11 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { RPGDate } from '~/models/Date'; import type { RPGDate } from '~/models/Date';
import { PhAlarm, PhMapPinArea } from '@phosphor-icons/vue' import { PhAlarm, PhCircleNotch, PhMapPinArea } from '@phosphor-icons/vue'
const { eventSkeleton } = storeToRefs(useCalendarEvents()) const { eventSkeleton, operationInProgress } = storeToRefs(useCalendarEvents())
const { resetSkeleton, submitSkeleton } = useCalendarEvents() const { resetSkeleton, submitSkeleton, cancelLatestRequest } = useCalendarEvents()
const popoverOpen = ref(false) const popoverOpen = ref(false)
const isLoading = ref(false)
const formErrors = reactive<{ message: string | null }>({ const formErrors = reactive<{ message: string | null }>({
message: null message: null
@@ -20,6 +21,12 @@ const props = defineProps<{
* Opens event creation's popover * Opens event creation's popover
*/ */
function openEventCreatePopover() { function openEventCreatePopover() {
// If another operation is in progress, whether it's another create popup or a modal, don't bother opening it
if (operationInProgress.value) {
popoverOpen.value = false
return
}
resetSkeleton() resetSkeleton()
popoverOpen.value = true popoverOpen.value = true
@@ -31,6 +38,11 @@ function openEventCreatePopover() {
} }
async function handleSubmit() { async function handleSubmit() {
// Prevent form submission if already loading
if (isLoading.value) return
isLoading.value = true
try { try {
await submitSkeleton() await submitSkeleton()
@@ -39,8 +51,31 @@ async function handleSubmit() {
if (err instanceof Error) { if (err instanceof Error) {
formErrors.message = err.message formErrors.message = err.message
} }
} finally {
isLoading.value = false
} }
} }
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event) {
if (isLoading.value) {
e.preventDefault()
}
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel() {
cancelLatestRequest()
isLoading.value = false
}
</script> </script>
<template> <template>
@@ -52,7 +87,13 @@ async function handleSubmit() {
:align="'center'" :align="'center'"
:side="'right'" :side="'right'"
:collision-padding="60" :collision-padding="60"
:disable-outside-pointer-events="true"
:trap-focus="true"
class="pl-3 min-w-96 bg-slate-900 border-slate-800" class="pl-3 min-w-96 bg-slate-900 border-slate-800"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
> >
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-y-4"> <div class="grid grid-cols-2 gap-y-4">
@@ -118,8 +159,18 @@ async function handleSubmit() {
</span> </span>
</div> </div>
<div class="text-right"> <div class="flex gap-2 justify-end">
<UiButton size="sm"> <Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
Annuler
</UiButton>
</Transition>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="opacity-50 animate-spin"/>
</Transition>
Sauvegarder Sauvegarder
</UiButton> </UiButton>
</div> </div>

View File

@@ -1,9 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhCircleNotch } from '@phosphor-icons/vue';
const { isDeleteEventModalOpen } = storeToRefs(useCalendarEvents()) const { isDeleteEventModalOpen } = storeToRefs(useCalendarEvents())
const { resetSkeleton, deleteEventFromSkeleton } = useCalendarEvents() const { resetSkeleton, deleteEventFromSkeleton, cancelLatestRequest } = useCalendarEvents()
const { eventSkeleton, lastActiveEvent } = storeToRefs(useCalendarEvents()) const { eventSkeleton, lastActiveEvent } = storeToRefs(useCalendarEvents())
const isLoading = ref(false)
const formErrors = reactive<{ message: string | null }>({ const formErrors = reactive<{ message: string | null }>({
message: null message: null
}) })
@@ -16,6 +20,10 @@ watch(isDeleteEventModalOpen, (hasOpened, _o) => {
}) })
async function handleAction() { async function handleAction() {
if (isLoading.value) return
isLoading.value = true
try { try {
await deleteEventFromSkeleton() await deleteEventFromSkeleton()
@@ -26,19 +34,42 @@ async function handleAction() {
} }
} finally { } finally {
resetSkeleton() resetSkeleton()
isLoading.value = false
} }
} }
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event) {
if (isLoading.value) {
e.preventDefault()
}
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel() {
cancelLatestRequest()
isLoading.value = false
}
</script> </script>
<template> <template>
<UiAlertDialog v-model:open="isDeleteEventModalOpen"> <UiAlertDialog v-model:open="isDeleteEventModalOpen">
<UiAlertDialogContent <UiAlertDialogContent
:align="'center'"
:side="'right'"
:collision-padding="60"
:disable-outside-pointer-events="true" :disable-outside-pointer-events="true"
:trap-focus="true" :trap-focus="true"
class="min-w-96 bg-slate-900 border-slate-800" class="min-w-96 bg-slate-900 border-slate-800"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="handleClosing"
> >
<UiAlertDialogTitle> Supprimer l'évènement</UiAlertDialogTitle> <UiAlertDialogTitle> Supprimer l'évènement</UiAlertDialogTitle>
@@ -46,7 +77,7 @@ async function handleAction() {
Les données associés à cet évènement seront supprimées et vous ne pourrez plus les récupérer ! Les données associés à cet évènement seront supprimées et vous ne pourrez plus les récupérer !
</UiAlertDialogDescription> </UiAlertDialogDescription>
<form> <form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-4"> <div class="grid grid-cols-2 gap-y-4">
<div class="text-red-500 ml-8"> <div class="text-red-500 ml-8">
<span class="text-sm"> <span class="text-sm">
@@ -54,16 +85,23 @@ async function handleAction() {
</span> </span>
</div> </div>
</div> </div>
</form>
<UiAlertDialogFooter> <footer class="flex gap-2 justify-end">
<UiAlertDialogCancel> <Transition name="fade-delay">
Annuler <UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
</UiAlertDialogCancel> Annuler
<UiAlertDialogAction class="destructive" @click="handleAction"> </UiButton>
Supprimer </Transition>
</UiAlertDialogAction>
</UiAlertDialogFooter> <UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
Supprimer
</UiButton>
</footer>
</form>
</UiAlertDialogContent> </UiAlertDialogContent>
</UiAlertDialog> </UiAlertDialog>
</template> </template>

View File

@@ -1,12 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PhAlarm, PhMapPinArea } from '@phosphor-icons/vue' import { PhAlarm, PhCircleNotch, PhMapPinArea } from '@phosphor-icons/vue'
import { VisuallyHidden } from 'radix-vue' import { VisuallyHidden } from 'radix-vue'
const { isEditEventModalOpen } = storeToRefs(useCalendarEvents()) const { isEditEventModalOpen } = storeToRefs(useCalendarEvents())
const { resetSkeleton, updateEventFromSkeleton } = useCalendarEvents() const { resetSkeleton, updateEventFromSkeleton, cancelLatestRequest } = useCalendarEvents()
const { eventSkeleton, lastActiveEvent } = storeToRefs(useCalendarEvents()) const { eventSkeleton, lastActiveEvent } = storeToRefs(useCalendarEvents())
const isLoading = ref(false)
const formErrors = reactive<{ message: string | null }>({ const formErrors = reactive<{ message: string | null }>({
message: null message: null
}) })
@@ -19,6 +21,10 @@ watch(isEditEventModalOpen, (hasOpened, _o) => {
}) })
async function handleAction() { async function handleAction() {
if (isLoading.value) return
isLoading.value = true
try { try {
await updateEventFromSkeleton() await updateEventFromSkeleton()
@@ -29,29 +35,52 @@ async function handleAction() {
} }
} finally { } finally {
resetSkeleton() resetSkeleton()
isLoading.value = false
} }
} }
/**
* Prevents the modal from closing if's still loading
*
* @param e The closing event (can be keydown or click)
*/
function handleClosing(e: Event) {
if (isLoading.value) {
e.preventDefault()
}
}
/**
* Click on the cancel button
*
* Must cancel the abortController in the store, and stop the loading
*/
function handleCancel() {
cancelLatestRequest()
isLoading.value = false
}
</script> </script>
<template> <template>
<UiAlertDialog v-model:open="isEditEventModalOpen"> <UiDialog v-model:open="isEditEventModalOpen">
<UiAlertDialogContent <UiDialogContent
:align="'center'"
:side="'right'"
:collision-padding="60"
:disable-outside-pointer-events="true" :disable-outside-pointer-events="true"
:trap-focus="true" :trap-focus="true"
class="pl-3 min-w-96 bg-slate-900 border-slate-800" class="pl-3 min-w-96 bg-slate-900 border-slate-800"
@escape-key-down="handleClosing"
@focus-outside="handleClosing"
@interact-outside="handleClosing"
@pointer-down-outside="(e) => e.preventDefault()"
> >
<VisuallyHidden> <VisuallyHidden>
<UiAlertDialogTitle> Modifier l'évènement</UiAlertDialogTitle> <UiDialogTitle> Modifier l'évènement</UiDialogTitle>
<UiAlertDialogDescription> <UiDialogDescription>
Mettre à jour les données de l'évènement Mettre à jour les données de l'évènement
</UiAlertDialogDescription> </UiDialogDescription>
</VisuallyHidden> </VisuallyHidden>
<form> <form @submit.prevent="handleAction">
<div class="grid grid-cols-2 gap-y-4"> <div class="grid grid-cols-2 gap-y-4">
<div class="col-span-2 ml-8"> <div class="col-span-2 ml-8">
<input <input
@@ -115,15 +144,23 @@ async function handleAction() {
</span> </span>
</div> </div>
</div> </div>
<footer class="flex gap-2 justify-end">
<Transition name="fade-delay">
<UiButton v-if="isLoading" type="button" size="sm" variant="destructive" @click.prevent="handleCancel">
Annuler
</UiButton>
</Transition>
<UiButton size="sm" :disabled="isLoading">
<Transition name="fade">
<PhCircleNotch v-if="isLoading" size="20" class="animate-spin"/>
</Transition>
Enregistrer
</UiButton>
</footer>
</form> </form>
<UiAlertDialogFooter> </UiDialogContent>
<UiAlertDialogCancel> </UiDialog>
Annuler
</UiAlertDialogCancel>
<UiAlertDialogAction @click="handleAction">
Sauvegarder
</UiAlertDialogAction>
</UiAlertDialogFooter>
</UiAlertDialogContent>
</UiAlertDialog>
</template> </template>

View File

@@ -146,7 +146,7 @@ const eventsNotDisplayed: ComputedRef<number> = computed<number>(() => eventsFo
</ClientOnly> </ClientOnly>
<ClientOnly> <ClientOnly>
<CalendarFormCreateEvent :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" /> <LazyCalendarFormCreateEvent :date btn-class="absolute inset-0 w-full h-full cursor-default z-0" />
</ClientOnly> </ClientOnly>
</div> </div>
</template> </template>

View File

@@ -1,7 +1,9 @@
import type { ShallowRef } from "vue"
export type SidebarMenuActionType = "event-search" export type SidebarMenuActionType = "event-search"
export interface SidebarMenuItem { export interface SidebarMenuItem {
phIcon: Component phIcon: ShallowRef
tooltip: string tooltip: string
action?: SidebarMenuActionType action?: SidebarMenuActionType
to?: string to?: string

View File

@@ -187,6 +187,12 @@ export const useCalendarEvents = defineStore('calendar-events', () => {
* EVENT CREATION FUNCTIONS * EVENT CREATION FUNCTIONS
*/ */
const lastActiveEvent = ref<CalendarEvent | null>() const lastActiveEvent = ref<CalendarEvent | null>()
const isCreatingEvent = ref<boolean>(false)
const isUpdatingEvent = ref<boolean>(false)
const isDeletingEvent = ref<boolean>(false)
const operationInProgress = computed(() => isCreatingEvent.value || isUpdatingEvent.value || isDeletingEvent.value)
let abortController: AbortController | null = null
/** /**
* Dummy event to hold creation data * Dummy event to hold creation data
*/ */
@@ -205,31 +211,58 @@ export const useCalendarEvents = defineStore('calendar-events', () => {
* We assume it's been sanitized by the caller * We assume it's been sanitized by the caller
*/ */
async function submitSkeleton() { async function submitSkeleton() {
abortController = new AbortController()
isCreatingEvent.value = true
try { try {
const res = await $fetch('/api/calendars/events/create', { method: 'POST', body: { event : eventSkeleton.value, calendarId: calendarId.value }}) const res = await $fetch('/api/calendars/events/create', { method: 'POST', body: { event : eventSkeleton.value, calendarId: calendarId.value }, signal: abortController.signal })
baseEvents.value.push(res) baseEvents.value.push(res)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} finally {
abortController = null
isCreatingEvent.value = false
} }
} }
async function updateEventFromSkeleton() { async function updateEventFromSkeleton() {
abortController = new AbortController()
isUpdatingEvent.value = true
try { try {
const res = await $fetch(`/api/calendars/events/${eventSkeleton.value.id}`, { method: 'PATCH', body: { event : eventSkeleton.value, calendarId: calendarId.value }}) const res = await $fetch(`/api/calendars/events/${eventSkeleton.value.id}`, { method: 'PATCH', body: { event : eventSkeleton.value, calendarId: calendarId.value }, signal: abortController.signal })
const eventIndex = baseEvents.value.findIndex(e => e.id === eventSkeleton.value.id) const eventIndex = baseEvents.value.findIndex(e => e.id === eventSkeleton.value.id)
baseEvents.value[eventIndex] = res baseEvents.value[eventIndex] = res
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} finally {
abortController = null
isUpdatingEvent.value = false
} }
} }
async function deleteEventFromSkeleton() { async function deleteEventFromSkeleton() {
abortController = new AbortController()
isDeletingEvent.value = true
try { try {
await $fetch(`/api/calendars/events/${eventSkeleton.value.id}`, { method: 'DELETE' }) await $fetch(`/api/calendars/events/${eventSkeleton.value.id}`, { method: 'DELETE', signal: abortController.signal })
const eventIndex = baseEvents.value.findIndex(e => e.id === eventSkeleton.value.id) const eventIndex = baseEvents.value.findIndex(e => e.id === eventSkeleton.value.id)
baseEvents.value.splice(eventIndex, 1) baseEvents.value.splice(eventIndex, 1)
} catch (err) { } catch (err) {
console.log(err) console.log(err)
} finally {
abortController = null
isDeletingEvent.value = false
}
}
function cancelLatestRequest() {
if (abortController) {
abortController.abort()
} }
} }
@@ -239,6 +272,11 @@ export const useCalendarEvents = defineStore('calendar-events', () => {
currentEvents, currentEvents,
getRelativeEventFromDate, getRelativeEventFromDate,
getRelativeEventFromEvent, getRelativeEventFromEvent,
cancelLatestRequest,
isCreatingEvent,
isUpdatingEvent,
isDeletingEvent,
operationInProgress,
eventSkeleton, eventSkeleton,
resetSkeleton, resetSkeleton,
submitSkeleton, submitSkeleton,