feat(KAN-20): unify dialogs feedback and protect ticket drafts
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { injectDialogRootContext } from 'reka-ui';
|
||||
import { watch } from 'vue';
|
||||
|
||||
/** Reka restores registered triggers; also support dialogs opened programmatically. */
|
||||
export function useDialogFocus() {
|
||||
const context = injectDialogRootContext();
|
||||
let previousFocus: HTMLElement | null = null;
|
||||
|
||||
watch(
|
||||
context.open,
|
||||
(open) => {
|
||||
if (open && typeof document !== 'undefined') {
|
||||
previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
}
|
||||
},
|
||||
{ flush: 'sync', immediate: true },
|
||||
);
|
||||
|
||||
function restoreFocus(event: Event): void {
|
||||
if (
|
||||
!event.defaultPrevented &&
|
||||
!context.triggerElement.value &&
|
||||
previousFocus?.isConnected
|
||||
) {
|
||||
event.preventDefault();
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { restoreFocus };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
PendingVisit,
|
||||
UrlMethodPair,
|
||||
UseFormSubmitOptions,
|
||||
} from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import type { InertiaForm } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
|
||||
import {
|
||||
formRequestHeader,
|
||||
isFormNetworkFailure,
|
||||
matchesFormRequest,
|
||||
} from '@/lib/form-feedback';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
|
||||
export function useFormProtection<T extends object>(
|
||||
form: InertiaForm<T>,
|
||||
options: { isDirty?: () => boolean } = {},
|
||||
) {
|
||||
const failure = ref<FormFeedbackState | null>(null);
|
||||
let ownVisit: PendingVisit | null = null;
|
||||
let requestId: string | null = null;
|
||||
const removeListeners: Array<() => void> = [];
|
||||
|
||||
const { confirmLeave } = useUnsavedChanges(
|
||||
() => form.processing || (options.isDirty?.() ?? form.isDirty),
|
||||
(visit) => visit === ownVisit,
|
||||
);
|
||||
|
||||
const state = computed<FormFeedbackState>(() => {
|
||||
if (form.processing) {
|
||||
return 'loading';
|
||||
}
|
||||
if (failure.value) {
|
||||
return failure.value;
|
||||
}
|
||||
if (Object.keys(form.errors).length > 0) {
|
||||
return 'validation';
|
||||
}
|
||||
return form.recentlySuccessful ? 'success' : 'idle';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
removeListeners.push(
|
||||
router.on('exception', (event) => {
|
||||
if (isFormNetworkFailure(event.detail.exception, requestId)) {
|
||||
event.preventDefault();
|
||||
failure.value = 'network';
|
||||
}
|
||||
}),
|
||||
router.on('invalid', (event) => {
|
||||
if (
|
||||
matchesFormRequest(event.detail.response.config, requestId)
|
||||
) {
|
||||
event.preventDefault();
|
||||
failure.value = 'server';
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => removeListeners.forEach((remove) => remove()));
|
||||
|
||||
function submit(
|
||||
target: UrlMethodPair,
|
||||
visitOptions: UseFormSubmitOptions = {},
|
||||
): void {
|
||||
if (form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequestId = crypto.randomUUID();
|
||||
form.submit(target, {
|
||||
...visitOptions,
|
||||
preserveState: true,
|
||||
headers: {
|
||||
...visitOptions.headers,
|
||||
[formRequestHeader]: nextRequestId,
|
||||
},
|
||||
onBefore: (visit) => {
|
||||
if (visitOptions.onBefore?.(visit) === false) {
|
||||
return false;
|
||||
}
|
||||
ownVisit = visit;
|
||||
},
|
||||
onStart: (visit) => {
|
||||
requestId = nextRequestId;
|
||||
failure.value = null;
|
||||
visitOptions.onStart?.(visit);
|
||||
},
|
||||
onError: (errors) => {
|
||||
failure.value = 'validation';
|
||||
return visitOptions.onError?.(errors);
|
||||
},
|
||||
onSuccess: (page) => {
|
||||
failure.value = null;
|
||||
return visitOptions.onSuccess?.(page);
|
||||
},
|
||||
onCancel: () => {
|
||||
failure.value = 'cancelled';
|
||||
visitOptions.onCancel?.();
|
||||
},
|
||||
onFinish: (visit) => {
|
||||
requestId = null;
|
||||
ownVisit = null;
|
||||
visitOptions.onFinish?.(visit);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { state, submit, confirmLeave };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PendingVisit } from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { onMounted, onUnmounted, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
export function useUnsavedChanges(
|
||||
dirty: MaybeRefOrGetter<boolean>,
|
||||
allowVisit: (visit: PendingVisit) => boolean = () => false,
|
||||
) {
|
||||
const { t } = useLocalization();
|
||||
let removeBeforeListener: (() => void) | undefined;
|
||||
|
||||
const confirmLeave = (): boolean =>
|
||||
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
|
||||
|
||||
const beforeUnload = (event: BeforeUnloadEvent): void => {
|
||||
if (toValue(dirty)) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
removeBeforeListener = router.on('before', (event) => {
|
||||
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the original visit and its callbacks; never replay a mutation.
|
||||
return confirmLeave();
|
||||
});
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeBeforeListener?.();
|
||||
window.removeEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
return { confirmLeave };
|
||||
}
|
||||
Reference in New Issue
Block a user