reusable dialogs loading state

This commit is contained in:
Gauthier Roebroeck 2025-06-10 12:17:30 +08:00
parent c78240c832
commit ddc849a57d
7 changed files with 248 additions and 150 deletions

View file

@ -12,9 +12,6 @@ export const useCreateUser = defineMutation(() => {
onSuccess: () => {
void queryCache.invalidateQueries({ key: ['users'] })
},
onError: (error) => {
console.log('create user error', error)
},
})
})
export const useUpdateUser = defineMutation(() => {
@ -28,9 +25,6 @@ export const useUpdateUser = defineMutation(() => {
onSuccess: () => {
void queryCache.invalidateQueries({ key: ['users'] })
},
onError: (error) => {
console.log('update user error', error)
},
})
})
@ -43,9 +37,6 @@ export const useUpdateUserPassword = defineMutation(() => {
password: newPassword,
},
}),
onError: (error) => {
console.log('update user password error', error)
},
})
})
@ -59,8 +50,5 @@ export const useDeleteUser = defineMutation(() => {
onSuccess: () => {
void queryCache.invalidateQueries({ key: ['users'] })
},
onError: (error) => {
console.log('delete user error', error)
},
})
})

View file

@ -4,61 +4,66 @@
:activator="activator"
:max-width="maxWidth"
>
<v-form
v-model="formValid"
@submit.prevent="submitForm()"
>
<v-card
:title="title"
:subtitle="subtitle"
<template #default="{ isActive }">
<v-form
v-model="formValid"
@submit.prevent="submitForm(isActive)"
:disabled="loading"
>
<template #text>
<slot name="warning" />
<slot name="text">
{{
$formatMessage(
{
description: 'Confirmation dialog: default hint to retype validation text',
defaultMessage: 'Please type {validateText} to confirm.',
id: 'eVoe+D',
},
{
validateText: validateText,
},
)
}}
</slot>
<v-card
:title="title"
:subtitle="subtitle"
:loading="loading"
>
<template #text>
<slot name="warning" />
<slot name="text">
{{
$formatMessage(
{
description: 'Confirmation dialog: default hint to retype validation text',
defaultMessage: 'Please type {validateText} to confirm.',
id: 'eVoe+D',
},
{
validateText: validateText,
},
)
}}
</slot>
<v-text-field
:rules="[rules.sameAs(validateText)]"
hide-details
class="mt-2"
/>
</template>
<v-text-field
:rules="[rules.sameAs(validateText)]"
hide-details
class="mt-2"
/>
</template>
<template #actions>
<v-spacer />
<v-btn
:text="
$formatMessage({
description: 'Confirmation dialog: Cancel button',
defaultMessage: 'Cancel',
id: 'pENCUD',
})
"
@click="close()"
/>
<v-btn
:disabled="!formValid"
:text="okText"
type="submit"
variant="elevated"
rounded="xs"
color="error"
/>
</template>
</v-card>
</v-form>
<template #actions>
<v-spacer />
<v-btn
:text="
$formatMessage({
description: 'Confirmation dialog: Cancel button',
defaultMessage: 'Cancel',
id: 'pENCUD',
})
"
@click="isActive.value = false"
/>
<v-btn
:loading="loading"
:disabled="!formValid"
:text="okText"
type="submit"
variant="elevated"
rounded="xs"
color="error"
/>
</template>
</v-card>
</v-form>
</template>
</v-dialog>
</template>
@ -74,10 +79,10 @@ const formValid = ref<boolean>(false)
const rules = useRules()
function submitForm() {
function submitForm(isActive: Ref<boolean, boolean>) {
if (formValid.value) {
emit('confirm')
close()
if (closeOnSave) isActive.value = false
}
}
@ -88,6 +93,8 @@ export interface DialogConfirmProps {
validateText?: string
maxWidth?: string | number
activator?: Element | string
loading?: boolean
closeOnSave?: boolean
}
const {
@ -97,9 +104,7 @@ const {
validateText = 'confirm',
maxWidth = undefined,
activator = undefined,
loading = false,
closeOnSave = true,
} = defineProps<DialogConfirmProps>()
function close() {
showDialog.value = false
}
</script>

View file

@ -4,57 +4,62 @@
:activator="activator"
:max-width="maxWidth"
>
<v-confirm-edit
v-model="record"
hide-actions
@save="close()"
>
<template #default="{ model: proxyModel, cancel, save, isPristine }">
<v-form
v-model="formValid"
@submit.prevent="submitForm(save)"
>
<v-card
:title="title"
:subtitle="subtitle"
<template #default="{ isActive }">
<v-confirm-edit
v-model="record"
hide-actions
@save="closeOnSave ? (isActive.value = false) : undefined"
>
<template #default="{ model: proxyModel, cancel, save, isPristine }">
<v-form
v-model="formValid"
@submit.prevent="submitForm(save)"
:disabled="loading"
>
<template #text>
<slot
name="text"
:proxy-model="proxyModel"
:cancel="cancel"
:save="save"
:is-pristine="isPristine"
/>
</template>
<v-card
:title="title"
:subtitle="subtitle"
:loading="loading"
>
<template #text>
<slot
name="text"
:proxy-model="proxyModel"
:cancel="cancel"
:save="save"
:is-pristine="isPristine"
/>
</template>
<template #actions>
<v-spacer />
<v-btn
:text="
$formatMessage({
description: 'ConfirmEdit dialog: Cancel button',
defaultMessage: 'Cancel',
id: 'G/T8/2',
})
"
@click="close()"
/>
<v-btn
:text="
$formatMessage({
description: 'ConfirmEdit dialog: Save button',
defaultMessage: 'Save',
id: 'N9WFH4',
})
"
type="submit"
/>
</template>
</v-card>
</v-form>
</template>
</v-confirm-edit>
<template #actions>
<v-spacer />
<v-btn
:text="
$formatMessage({
description: 'ConfirmEdit dialog: Cancel button',
defaultMessage: 'Cancel',
id: 'G/T8/2',
})
"
@click="isActive.value = false"
/>
<v-btn
:text="
$formatMessage({
description: 'ConfirmEdit dialog: Save button',
defaultMessage: 'Save',
id: 'N9WFH4',
})
"
type="submit"
:loading="loading"
/>
</template>
</v-card>
</v-form>
</template>
</v-confirm-edit>
</template>
</v-dialog>
</template>
@ -77,6 +82,8 @@ export interface DialogConfirmEditProps {
subtitle?: string
maxWidth?: string | number
activator?: Element | string
loading?: boolean
closeOnSave?: boolean
}
const {
@ -84,9 +91,7 @@ const {
subtitle = undefined,
maxWidth = undefined,
activator = undefined,
loading = false,
closeOnSave = true,
} = defineProps<DialogConfirmEditProps>()
function close() {
showDialog.value = false
}
</script>

View file

@ -1,8 +1,10 @@
<template>
<DialogConfirm
v-model="showDialog"
:loading="loading"
v-bind="confirm.dialogProps"
:activator="confirm.activator"
@confirm="confirm.confirmCallback()"
@confirm="confirm.callback(hideDialog, setLoading)"
>
<template #warning>
<component
@ -21,7 +23,19 @@
import { useDialogsStore } from '@/stores/dialogs'
import { storeToRefs } from 'pinia'
const showDialog = ref<boolean>(false)
const loading = ref<boolean>(false)
const { confirm } = storeToRefs(useDialogsStore())
function hideDialog() {
showDialog.value = false
loading.value = false
}
function setLoading(isLoading: boolean) {
loading.value = isLoading
}
</script>
<style scoped></style>

View file

@ -1,9 +1,11 @@
<template>
<DialogConfirmEdit
v-model="showDialog"
:loading="loading"
v-bind="confirmEdit.dialogProps"
:activator="confirmEdit.activator"
v-model:record="confirmEdit.record"
@update:record="confirmEdit.recordUpdatedCallback()"
@update:record="confirmEdit.callback(hideDialog, setLoading)"
>
<template #text="{ proxyModel }">
<component
@ -23,7 +25,19 @@
import { useDialogsStore } from '@/stores/dialogs'
import { storeToRefs } from 'pinia'
const showDialog = ref<boolean>(false)
const loading = ref<boolean>(false)
const { confirmEdit } = storeToRefs(useDialogsStore())
function hideDialog() {
showDialog.value = false
loading.value = false
}
function setLoading(isLoading: boolean) {
loading.value = isLoading
}
</script>
<style scoped></style>

View file

@ -85,7 +85,7 @@ import mdiLockReset from '~icons/mdi/lock-reset'
import mdiPencil from '~icons/mdi/pencil'
import mdiDelete from '~icons/mdi/delete'
import { useUsers } from '@/colada/queries/users'
import { komgaClient } from '@/api/komga-client'
import { type ErrorCause, komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
import { useCurrentUser } from '@/colada/queries/current-user'
import { UserRoles } from '@/types/UserRoles'
@ -102,6 +102,10 @@ import { useLibraries } from '@/colada/queries/libraries'
import { commonMessages } from '@/utils/i18n/common-messages'
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
import { useMessagesStore } from '@/stores/messages'
import { useIntl } from 'vue-intl'
const intl = useIntl()
// API data
const { data: users, error, isLoading, refetch: refetchUsers } = useUsers()
@ -157,11 +161,12 @@ const currentAction = ref<ACTION>()
const { confirmEdit: dialogConfirmEdit, confirm: dialogConfirm } = storeToRefs(useDialogsStore())
const { mutate: mutateCreateUser } = useCreateUser()
const { mutate: mutateUser } = useUpdateUser()
const { mutate: mutateUserPassword } = useUpdateUserPassword()
const { mutate: mutateDeleteUser } = useDeleteUser()
const { mutateAsync: mutateCreateUser } = useCreateUser()
const { mutateAsync: mutateUser } = useUpdateUser()
const { mutateAsync: mutateUserPassword } = useUpdateUserPassword()
const { mutateAsync: mutateDeleteUser } = useDeleteUser()
const { data: libraries } = useLibraries()
const messagesStore = useMessagesStore()
enum ACTION {
ADD,
@ -177,6 +182,7 @@ function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
dialogConfirmEdit.value.dialogProps = {
title: 'Add User',
maxWidth: 600,
closeOnSave: false,
}
dialogConfirmEdit.value.slot = {
component: markRaw(FormUserEdit),
@ -196,13 +202,14 @@ function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
restriction: 'NONE',
},
} as components['schemas']['UserCreationDto']
dialogConfirmEdit.value.recordUpdatedCallback = handleDialogConfirmation
dialogConfirmEdit.value.callback = handleDialogConfirmation
break
case ACTION.EDIT:
dialogConfirmEdit.value.dialogProps = {
title: 'Edit User',
subtitle: user?.email,
maxWidth: 600,
closeOnSave: false,
}
dialogConfirmEdit.value.slot = {
component: markRaw(FormUserEdit),
@ -223,7 +230,7 @@ function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
restriction: 'NONE',
},
} as components['schemas']['UserUpdateDto']
dialogConfirmEdit.value.recordUpdatedCallback = handleDialogConfirmation
dialogConfirmEdit.value.callback = handleDialogConfirmation
break
case ACTION.DELETE:
dialogConfirm.value.dialogProps = {
@ -232,18 +239,20 @@ function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
maxWidth: 600,
validateText: user?.email,
okText: 'Delete',
closeOnSave: false,
}
dialogConfirm.value.slotWarning = {
component: markRaw(NoticeUserDeletion),
props: {},
}
dialogConfirm.value.confirmCallback = handleDialogConfirmation
dialogConfirm.value.callback = handleDialogConfirmation
break
case ACTION.PASSWORD:
dialogConfirmEdit.value.dialogProps = {
title: 'Change Password',
subtitle: user?.email,
maxWidth: 400,
closeOnSave: false,
}
dialogConfirmEdit.value.slot = {
component: markRaw(FormUserChangePassword),
@ -251,29 +260,92 @@ function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
}
// password change initiated with an empty string
dialogConfirmEdit.value.record = ''
dialogConfirmEdit.value.recordUpdatedCallback = handleDialogConfirmation
dialogConfirmEdit.value.callback = handleDialogConfirmation
}
userRecord.value = user
}
function handleDialogConfirmation() {
function handleDialogConfirmation(
hideDialog: () => void,
setLoading: (isLoading: boolean) => void,
) {
let mutation: Promise<unknown> | undefined
let successMessage: string | undefined
setLoading(true)
switch (currentAction.value) {
case ACTION.ADD:
mutateCreateUser(dialogConfirmEdit.value.record as components['schemas']['UserCreationDto'])
const newUser = dialogConfirmEdit.value.record as components['schemas']['UserCreationDto']
mutation = mutateCreateUser(newUser)
successMessage = intl.formatMessage(
{
description: 'Snackbar notification shown upon successful user creation',
defaultMessage: 'User created: {email}',
id: 'egrxd6',
},
{
email: newUser.email,
},
)
break
case ACTION.EDIT:
mutateUser(dialogConfirmEdit.value.record as components['schemas']['UserDto'])
const editUser = dialogConfirmEdit.value.record as components['schemas']['UserDto']
mutation = mutateUser(editUser)
successMessage = intl.formatMessage(
{
description: 'Snackbar notification shown upon successful user update',
defaultMessage: 'User updated: {email}',
id: 'kvbi4j',
},
{
email: editUser.email,
},
)
break
case ACTION.DELETE:
mutateDeleteUser(userRecord.value!.id)
mutation = mutateDeleteUser(userRecord.value!.id)
successMessage = intl.formatMessage(
{
description: 'Snackbar notification shown upon successful user deletion',
defaultMessage: 'User deleted: {email}',
id: 'V/OYJE',
},
{
email: userRecord.value!.email,
},
)
break
case ACTION.PASSWORD:
mutateUserPassword({
mutation = mutateUserPassword({
userId: userRecord.value!.id,
newPassword: dialogConfirmEdit.value.record as string,
})
successMessage = intl.formatMessage(
{
description: "Snackbar notification shown upon successful user's password modification",
defaultMessage: 'Password changed for user: {email}',
id: 'JbF1nK',
},
{
email: userRecord.value!.email,
},
)
break
}
mutation
?.then(() => {
hideDialog()
if (successMessage) messagesStore.messages.push({ text: successMessage })
})
.catch((error) => {
messagesStore.messages.push({
text:
(error.cause as ErrorCause).message || intl.formatMessage(commonMessages.networkError),
})
setLoading(false)
})
}
</script>

View file

@ -16,7 +16,7 @@ export const useDialogsStore = defineStore('dialogs', {
props: {},
},
record: undefined,
recordUpdatedCallback: () => {},
callback: () => {},
} as DialogConfirmEditActivation,
confirm: {
dialogProps: {},
@ -24,24 +24,24 @@ export const useDialogsStore = defineStore('dialogs', {
component: undefined,
props: {},
},
confirmCallback: () => {},
callback: () => {},
} as DialogConfirmActivation,
}),
})
interface DialogConfirmEditActivation {
interface DialogActivation<T> {
activator?: Element | string
dialogProps: DialogConfirmEditProps
slot: ComponentWithProps
record?: unknown
recordUpdatedCallback: () => void
dialogProps: T
callback: (hideDialog: () => void, setLoading: (isLoading: boolean) => void) => void
}
interface DialogConfirmActivation {
activator?: Element | string
dialogProps: DialogConfirmProps
interface DialogConfirmEditActivation extends DialogActivation<DialogConfirmEditProps> {
slot: ComponentWithProps
record?: unknown
}
interface DialogConfirmActivation extends DialogActivation<DialogConfirmProps> {
slotWarning: ComponentWithProps
confirmCallback: () => void
}
interface ComponentWithProps {