feat: add form guards to prevent accidental data loss

This commit is contained in:
arabcoders 2026-04-15 00:37:12 +03:00
parent 360d2f3df8
commit 182a2e97e6
16 changed files with 582 additions and 45 deletions

View file

@ -523,6 +523,7 @@ import { match_str } from '~/utils/ytdlp';
const emitter = defineEmits<{
(e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
}>();
@ -557,6 +558,16 @@ const testData = ref<{
const showOptions = ref(false);
const ytDlpOpt = ref<AutoCompleteOptions>([]);
const dirtySource = computed(() => ({
reference: props.reference ?? null,
form: normalizeCondition(form),
importString: importString.value,
showImport: showImport.value,
newExtraKey: newExtraKey.value,
newExtraValue: newExtraValue.value,
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
const fieldUi = {
label: 'font-semibold text-default',
container: 'space-y-2',
@ -586,10 +597,20 @@ watch(
() => props.item,
(value) => {
Object.assign(form, normalizeCondition(value));
importString.value = '';
newExtraKey.value = '';
newExtraValue.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
},
{ deep: true },
);
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
watch(
() => config.ytdlp_options,
(newOptions) =>
@ -855,4 +876,9 @@ const updateExtraValue = (key: string, rawValue: string): void => {
[key]: rawValue.trim() ? parseValue(rawValue.trim()) : '',
};
};
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script>

View file

@ -248,6 +248,7 @@ import { decode } from '~/utils';
const emitter = defineEmits<{
(e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'submit', payload: { reference: number | null | undefined; item: DLField }): void;
}>();
@ -268,6 +269,14 @@ const ytDlpOptions = ref<AutoCompleteOptions>([]);
const showImport = useStorage('showDlFieldsImport', false);
const importString = ref('');
const dirtySource = computed(() => ({
reference: props.reference ?? null,
form: normalizeField(form),
importString: importString.value,
showImport: showImport.value,
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
const fieldUi = {
label: 'font-semibold text-default',
container: 'space-y-2',
@ -288,10 +297,18 @@ watch(
() => props.item,
(value) => {
Object.assign(form, normalizeField(value));
importString.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
},
{ deep: true },
);
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
watch(
() => config.ytdlp_options,
(newOptions) =>
@ -400,4 +417,9 @@ const checkInfo = (): void => {
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
};
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script>

View file

@ -443,6 +443,7 @@ import type { notification, notificationRequestHeaderItem } from '~/types/notifi
const emitter = defineEmits<{
(event: 'cancel'): void;
(event: 'dirty-change', dirty: boolean): void;
(event: 'submit', payload: { reference: number | undefined; item: notification }): void;
}>();
@ -489,14 +490,30 @@ const selectUi = {
const isAppriseTarget = computed(() => Boolean(form.request.url) && isApprise(form.request.url));
const dirtySource = computed(() => ({
reference: props.reference ?? null,
form: normalizeNotification(form),
importString: importString.value,
showImport: showImport.value,
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
watch(
() => props.item,
(value) => {
Object.assign(form, normalizeNotification(value));
importString.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
},
{ deep: true },
);
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
function createDefaultNotification(): notification {
return {
name: '',
@ -634,4 +651,9 @@ const importItem = async (): Promise<void> => {
toast.error(`Failed to import task. ${error.message}`);
}
};
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script>

View file

@ -359,6 +359,7 @@ import { normalizePresetName } from '~/utils';
const emitter = defineEmits<{
(event: 'cancel'): void;
(event: 'dirty-change', dirty: boolean): void;
(event: 'submit', payload: { reference: number | null; preset: Preset }): void;
}>();
@ -420,6 +421,15 @@ const textareaUi = {
const importPresetItems = computed(() => selectItems.value);
const dirtySource = computed(() => ({
reference: props.reference ?? null,
form: JSON.parse(JSON.stringify(form)),
importString: importString.value,
selectedPreset: selectedPreset.value,
showImport: showImport.value,
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
watch(
() => props.preset,
(value) => {
@ -434,10 +444,19 @@ watch(
priority: 0,
...JSON.parse(JSON.stringify(value || {})),
});
importString.value = '';
selectedPreset.value = '';
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
},
{ deep: true },
);
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
watch(
() => config.ytdlp_options,
(newOptions) =>
@ -625,4 +644,9 @@ const importExistingPreset = async (): Promise<void> => {
await nextTick();
selectedPreset.value = '';
};
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script>

View file

@ -622,6 +622,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'submit', payload: TaskDefinitionDocument): void;
(e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(e: 'import-existing', id: number): void;
}>();
@ -715,6 +716,16 @@ const existingDefinitionItems = computed(() => {
}));
});
const dirtySource = computed(() => ({
mode: mode.value,
showImport: showImport.value,
importString: importString.value,
selectedExisting: selectedExisting.value,
jsonText: jsonText.value,
guiState: JSON.parse(JSON.stringify(guiState)),
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
const resetGuiState = (state: GuiState): void => {
guiState.name = state.name;
guiState.priority = state.priority;
@ -1034,6 +1045,10 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
containerSelector: '',
fields: [defaultField()],
});
nextTick(() => {
markClean();
emit('dirty-change', false);
});
return;
}
@ -1057,6 +1072,11 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
mode.value = 'advanced';
errorMessage.value = 'Failed to prepare definition for editing.';
}
nextTick(() => {
markClean();
emit('dirty-change', false);
});
};
const importFromString = (): void => {
@ -1095,6 +1115,8 @@ watch(
{ immediate: true },
);
watch(isDirty, (value: boolean) => emit('dirty-change', value));
const switchMode = (next: EditorMode): void => {
if (isBusy.value || next === mode.value) {
return;

View file

@ -513,6 +513,7 @@ const props = defineProps<{
const emitter = defineEmits<{
(e: 'cancel'): void;
(e: 'dirty-change', dirty: boolean): void;
(
e: 'submit',
payload: { reference: number | null | undefined; task: Task | Task[]; archive_all?: boolean },
@ -555,6 +556,15 @@ const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i;
const form = reactive<Task>(createDefaultTask(props.task));
const dirtySource = computed(() => ({
reference: props.reference ?? null,
form: JSON.parse(JSON.stringify(form)),
import_string: import_string.value,
showImport: showImport.value,
archiveAllAfterAdd: archiveAllAfterAdd.value,
}));
const { isDirty, markClean } = useDirtyState(dirtySource);
const fieldUi = {
label: 'font-semibold text-default',
container: 'space-y-2',
@ -611,6 +621,13 @@ watch(
if (!value?.preset) {
form.preset = toRaw(config.app.default_preset);
}
import_string.value = '';
archiveAllAfterAdd.value = false;
nextTick(() => {
markClean();
emitter('dirty-change', false);
});
},
{ immediate: true, deep: true },
);
@ -637,6 +654,8 @@ watch(
},
);
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
const handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
const isTextarea = target.tagName === 'TEXTAREA';
@ -931,4 +950,9 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
return getPresetDefault(form.preset, type, ret);
};
onMounted(() => {
markClean();
emitter('dirty-change', false);
});
</script>

View file

@ -0,0 +1,105 @@
import { onBeforeRouteLeave, onBeforeRouteUpdate, onBeforeUnmount } from '#imports';
import { computed, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
import { useDialog } from '~/composables/useDialog';
type DirtyCloseGuardOptions = {
dirty: MaybeRefOrGetter<boolean>;
title?: string;
message?: string;
confirmText?: string;
cancelText?: string;
confirmColor?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral';
onDiscard?: () => void | Promise<void>;
};
export const useDirtyCloseGuard = (open: Ref<boolean>, options: DirtyCloseGuardOptions) => {
const dialog = useDialog();
let pendingCloseRequest: Promise<boolean> | null = null;
const isDirty = computed<boolean>(() => Boolean(toValue(options.dirty)));
const shouldGuard = computed<boolean>(() => Boolean(open.value) && true === isDirty.value);
const confirmClose = async (): Promise<boolean> => {
if (false === isDirty.value) {
open.value = false;
return true;
}
const { status } = await dialog.confirmDialog({
title: options.title ?? 'Discard changes?',
message: options.message ?? 'You have unsaved changes. Do you want to discard them?',
confirmText: options.confirmText ?? 'Discard changes',
cancelText: options.cancelText ?? 'Keep editing',
confirmColor: options.confirmColor ?? 'warning',
});
if (true !== status) {
return false;
}
await options.onDiscard?.();
open.value = false;
return true;
};
const requestClose = async (): Promise<boolean> => {
if (pendingCloseRequest) {
return pendingCloseRequest;
}
pendingCloseRequest = confirmClose().finally(() => {
pendingCloseRequest = null;
});
return pendingCloseRequest;
};
const handleOpenChange = async (value: boolean): Promise<void> => {
if (true === value) {
open.value = true;
return;
}
await requestClose();
};
onBeforeRouteLeave(async () => {
if (false === shouldGuard.value) {
return true;
}
return await requestClose();
});
onBeforeRouteUpdate(async () => {
if (false === shouldGuard.value) {
return true;
}
return await requestClose();
});
if ('undefined' !== typeof window) {
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
if (false === shouldGuard.value) {
return;
}
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
});
}
return {
isDirty,
requestClose,
handleOpenChange,
};
};

View file

@ -0,0 +1,20 @@
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue';
const serializeDirtyValue = <T>(value: T): string => JSON.stringify(toValue(value));
export const useDirtyState = <T>(source: MaybeRefOrGetter<T>) => {
const snapshot = ref<string>('');
const markClean = (): void => {
snapshot.value = serializeDirtyValue(toValue(source));
};
const isDirty = computed<boolean>(() => {
return snapshot.value !== serializeDirtyValue(toValue(source));
});
return {
isDirty,
markClean,
};
};

View file

@ -1,6 +1,7 @@
import { computed, ref } from 'vue';
import type { Preset } from '~/types/presets';
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
import { cleanObject, prettyName } from '~/utils';
type EditablePreset = Partial<Preset> & {
@ -34,14 +35,31 @@ const sanitizePreset = (item: Preset | EditablePreset): Partial<Preset> => {
export const usePresetEditor = () => {
const presetsStore = usePresets();
const dialog = useDialog();
const isOpen = ref(false);
const reference = ref<number | null>(null);
const preset = ref<Partial<Preset>>(makeEmptyPreset());
const initialSnapshot = ref('');
const dirty = ref(false);
const sessionId = ref(0);
const discardEditor = (): void => {
dirty.value = false;
reference.value = null;
preset.value = makeEmptyPreset();
};
const {
isDirty,
requestClose: requestCloseGuard,
handleOpenChange,
} = useDirtyCloseGuard(isOpen, {
dirty,
message: 'You have unsaved preset changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
const modalTitle = computed(() => {
return reference.value ? `Edit - ${prettyName(preset.value.name || '')}` : 'Add';
});
@ -53,39 +71,27 @@ export const usePresetEditor = () => {
const modalKey = computed(() => `${reference.value ?? 'create'}:${sessionId.value}`);
const reset = (): void => {
reference.value = null;
preset.value = makeEmptyPreset();
initialSnapshot.value = '';
discardEditor();
};
const close = (): void => {
dirty.value = false;
isOpen.value = false;
reset();
};
const snapshot = (item: Partial<Preset>): string =>
JSON.stringify(sanitizePreset(item as Preset));
const isDirty = computed(() => {
if (!isOpen.value) {
return false;
}
return snapshot(preset.value) !== initialSnapshot.value;
});
const openCreate = (): void => {
reset();
dirty.value = false;
sessionId.value += 1;
initialSnapshot.value = snapshot(preset.value);
isOpen.value = true;
};
const openEdit = (item: Preset | EditablePreset): void => {
dirty.value = false;
reference.value = item.id ?? null;
preset.value = sanitizePreset(item);
sessionId.value += 1;
initialSnapshot.value = snapshot(preset.value);
isOpen.value = true;
};
@ -94,22 +100,7 @@ export const usePresetEditor = () => {
return;
}
if (!isDirty.value) {
close();
return;
}
const { status } = await dialog.confirmDialog({
title: 'Discard changes?',
message: 'You have unsaved changes. Close the preset editor and discard them?',
confirmText: 'Discard',
cancelText: 'Keep editing',
confirmColor: 'warning',
});
if (status === true) {
close();
}
await requestCloseGuard();
};
const submit = async ({
@ -144,10 +135,12 @@ export const usePresetEditor = () => {
modalTitle,
modalDescription,
isDirty,
dirty,
addInProgress: presetsStore.addInProgress,
openCreate,
openEdit,
close,
handleOpenChange,
requestClose,
reset,
submit,

View file

@ -459,7 +459,7 @@
:description="modalDescription"
:dismissible="!conditions.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<ConditionForm
@ -467,7 +467,8 @@
:addInProgress="conditions.addInProgress.value"
:reference="itemRef"
:item="item as Condition"
@cancel="closeEditor()"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem"
/>
</template>
@ -504,6 +505,7 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
const item = ref<Partial<Condition>>({});
const itemRef = ref<number | null | undefined>(null);
const editorOpen = ref(false);
const editorDirty = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
@ -555,6 +557,21 @@ const modalKey = computed(
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
);
const discardEditor = (): void => {
editorDirty.value = false;
item.value = {};
itemRef.value = null;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(editorOpen, {
dirty: editorDirty,
message: 'You have unsaved condition changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
watch(showFilter, (value) => {
if (!value) {
query.value = '';
@ -614,6 +631,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => {
item.value = {};
itemRef.value = null;
editorDirty.value = false;
};
const closeEditor = (): void => {
@ -721,6 +739,7 @@ const updateItem = async ({
};
const editItem = (value: Condition): void => {
editorDirty.value = false;
item.value = JSON.parse(JSON.stringify(value)) as Condition;
itemRef.value = value.id;
editorOpen.value = true;

View file

@ -382,7 +382,7 @@
:description="modalDescription"
:dismissible="!dlFields.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<DLFieldForm
@ -390,7 +390,8 @@
:addInProgress="dlFields.addInProgress.value"
:reference="itemRef"
:item="item as DLField"
@cancel="closeEditor()"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem"
/>
</template>
@ -425,6 +426,7 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
const item = ref<Partial<DLField>>({});
const itemRef = ref<number | null | undefined>(null);
const editorOpen = ref(false);
const editorDirty = ref(false);
const query = ref('');
const showFilter = ref(false);
const filterInput = ref<HTMLInputElement | null>(null);
@ -472,6 +474,21 @@ const modalKey = computed(
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
);
const discardEditor = (): void => {
editorDirty.value = false;
item.value = {};
itemRef.value = null;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(editorOpen, {
dirty: editorDirty,
message: 'You have unsaved custom field changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
watch(showFilter, (value) => {
if (!value) {
query.value = '';
@ -531,6 +548,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => {
item.value = {};
itemRef.value = null;
editorDirty.value = false;
};
const closeEditor = (): void => {
@ -629,6 +647,7 @@ const updateItem = async ({
};
const editItem = (field: DLField): void => {
editorDirty.value = false;
item.value = JSON.parse(JSON.stringify(field)) as DLField;
itemRef.value = field.id;
editorOpen.value = true;

View file

@ -512,7 +512,7 @@
:description="modalDescription"
:dismissible="!addInProgress"
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<NotificationForm
@ -521,7 +521,8 @@
:reference="targetRef"
:item="target"
:allowedEvents="allowedEvents"
@cancel="closeEditor()"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem"
/>
</template>
@ -562,6 +563,7 @@ const page = ref(1);
const targetRef = ref<number | undefined>(undefined);
const target = ref<notification>(defaultState());
const editorOpen = ref(false);
const editorDirty = ref(false);
const sendingTest = ref(false);
const query = ref('');
const showFilter = ref(false);
@ -583,6 +585,21 @@ const modalKey = computed(
() => `${targetRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
);
const discardEditor = (): void => {
editorDirty.value = false;
target.value = defaultState();
targetRef.value = undefined;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(editorOpen, {
dirty: editorDirty,
message: 'You have unsaved notification changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
const filteredTargets = computed<notification[]>(() => {
const normalizedQuery = query.value?.toLowerCase();
const items = notifications.value as notification[];
@ -672,6 +689,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
const resetEditor = (): void => {
target.value = defaultState();
targetRef.value = undefined;
editorDirty.value = false;
};
const closeEditor = (): void => {
@ -694,6 +712,7 @@ const toggleMasterSelection = (): void => {
};
const editItem = (item: notification): void => {
editorDirty.value = false;
target.value = JSON.parse(JSON.stringify(item)) as notification;
targetRef.value = item.id ?? undefined;
editorOpen.value = true;

View file

@ -424,7 +424,7 @@
:description="editor.modalDescription.value"
:dismissible="!editor.addInProgress.value"
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && void editor.requestClose()"
@update:open="(open) => void editor.handleOpenChange(open)"
>
<template #body>
<PresetForm
@ -434,6 +434,7 @@
:preset="editor.preset.value"
:presets="presets"
@cancel="() => void editor.requestClose()"
@dirty-change="(dirty) => (editor.dirty.value = dirty)"
@submit="editor.submit"
/>
</template>

View file

@ -404,7 +404,7 @@
:description="editorDescription"
:dismissible="!editorLoading && !editorSubmitting"
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<TaskDefinitionEditor
@ -414,7 +414,8 @@
:loading="editorLoading"
:submitting="editorSubmitting"
@submit="submitDefinition"
@cancel="closeEditor"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@import-existing="importExistingDefinition"
/>
</template>
@ -491,6 +492,7 @@ const definitions = computed<TaskDefinitionSummary[]>(() => [...definitionsRef.v
const { confirmDialog } = useDialog();
const isEditorOpen = ref(false);
const editorDirty = ref(false);
const editorMode = ref<'create' | 'edit'>('create');
const editorLoading = ref(false);
const editorSubmitting = ref(false);
@ -578,6 +580,24 @@ const showImportByDefault = computed(
() => editorMode.value === 'create' && !hideImportByDefault.value,
);
const discardEditor = (): void => {
editorDirty.value = false;
workingDefinition.value = null;
workingId.value = null;
editorLoading.value = false;
editorSubmitting.value = false;
hideImportByDefault.value = false;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(isEditorOpen, {
dirty: editorDirty,
message: 'You have unsaved task definition changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
watch(showFilter, (value) => {
if (!value) {
query.value = '';
@ -628,6 +648,7 @@ const toggleMasterSelection = (): void => {
};
const openCreate = (): void => {
editorDirty.value = false;
editorMode.value = 'create';
workingId.value = null;
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
@ -638,6 +659,7 @@ const openCreate = (): void => {
};
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
editorDirty.value = false;
editorMode.value = 'edit';
workingId.value = summary.id;
workingDefinition.value = null;
@ -668,6 +690,7 @@ const importExistingDefinition = async (id: number): Promise<void> => {
return;
}
editorDirty.value = false;
editorMode.value = 'create';
workingId.value = null;
workingDefinition.value = {
@ -687,6 +710,7 @@ const closeEditor = (): void => {
return;
}
editorDirty.value = false;
isEditorOpen.value = false;
workingDefinition.value = null;
workingId.value = null;

View file

@ -648,7 +648,7 @@
:description="editorDescription"
:dismissible="!addInProgress"
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
@update:open="(open) => !open && closeEditor()"
@update:open="handleEditorOpenChange"
>
<template #body>
<TaskForm
@ -656,7 +656,8 @@
:addInProgress="addInProgress"
:reference="taskRef"
:task="task as Task"
@cancel="closeEditor"
@cancel="() => void requestCloseEditor()"
@dirty-change="(dirty) => (editorDirty = dirty)"
@submit="updateItem"
/>
</template>
@ -729,6 +730,7 @@ const createEmptyTask = (): Partial<Task> => ({
const task = ref<Partial<Task>>(createEmptyTask());
const taskRef = ref<number | null>(null);
const toggleForm = ref(false);
const editorDirty = ref(false);
const selectedElms = ref<number[]>([]);
const massRun = ref(false);
const massDelete = ref(false);
@ -757,6 +759,21 @@ const editorDescription = computed(() => {
const formKey = computed(() => `${taskRef.value ?? 'new'}:${editorSessionId.value}`);
const discardEditor = (): void => {
editorDirty.value = false;
task.value = createEmptyTask();
taskRef.value = null;
};
const { handleOpenChange: handleEditorOpenChange, requestClose: requestCloseEditor } =
useDirtyCloseGuard(toggleForm, {
dirty: editorDirty,
message: 'You have unsaved task changes. Do you want to discard them?',
onDiscard: async () => {
discardEditor();
},
});
const filteredTasks = computed(() => {
const normalizedQuery = query.value?.toLowerCase();
if (!normalizedQuery) {
@ -925,6 +942,7 @@ const reloadContent = async (fromMounted: boolean = false) => {
const resetForm = (closeForm: boolean = false) => {
task.value = createEmptyTask();
taskRef.value = null;
editorDirty.value = false;
if (closeForm) {
toggleForm.value = false;
@ -1068,6 +1086,7 @@ const updateItem = async ({
};
const editItem = (item: Task) => {
editorDirty.value = false;
task.value = { ...item };
taskRef.value = item.id ?? null;
editorSessionId.value += 1;

View file

@ -0,0 +1,178 @@
import { beforeAll, beforeEach, afterEach, describe, expect, it, mock } from 'bun:test';
import { ref } from 'vue';
type DialogResult = { status: boolean; value: null };
type RouteGuard = () => Promise<boolean> | boolean;
let beforeRouteLeaveHandler: RouteGuard | null = null;
let beforeRouteUpdateHandler: RouteGuard | null = null;
let unmountHandlers: Array<() => void> = [];
const confirmDialogMock = mock<() => Promise<DialogResult>>(() =>
Promise.resolve({ status: true, value: null }),
);
mock.module('#imports', () => ({
onBeforeRouteLeave: (handler: RouteGuard) => {
beforeRouteLeaveHandler = handler;
},
onBeforeRouteUpdate: (handler: RouteGuard) => {
beforeRouteUpdateHandler = handler;
},
onBeforeUnmount: (handler: () => void) => {
unmountHandlers.push(handler);
},
}));
mock.module('~/composables/useDialog', () => ({
useDialog: () => ({
confirmDialog: confirmDialogMock,
}),
}));
let useDirtyCloseGuard: typeof import('~/composables/useDirtyCloseGuard').useDirtyCloseGuard;
beforeAll(async () => {
({ useDirtyCloseGuard } = await import('~/composables/useDirtyCloseGuard'));
});
beforeEach(() => {
beforeRouteLeaveHandler = null;
beforeRouteUpdateHandler = null;
unmountHandlers = [];
confirmDialogMock.mockReset();
confirmDialogMock.mockImplementation(() => Promise.resolve({ status: true, value: null }));
});
afterEach(() => {
for (const handler of unmountHandlers) {
handler();
}
beforeRouteLeaveHandler = null;
beforeRouteUpdateHandler = null;
unmountHandlers = [];
});
describe('useDirtyCloseGuard', () => {
it('confirms discard on route leave when the editor is open and dirty', async () => {
const open = ref(true);
const dirty = ref(true);
const onDiscard = mock(() => {});
useDirtyCloseGuard(open, {
dirty,
onDiscard,
});
expect(beforeRouteLeaveHandler).not.toBeNull();
const allowed = await beforeRouteLeaveHandler?.();
expect(allowed).toBe(true);
expect(confirmDialogMock).toHaveBeenCalledTimes(1);
expect(onDiscard).toHaveBeenCalledTimes(1);
expect(open.value).toBe(false);
});
it('blocks route updates when the user keeps editing', async () => {
const open = ref(true);
const dirty = ref(true);
confirmDialogMock.mockImplementationOnce(() => Promise.resolve({ status: false, value: null }));
useDirtyCloseGuard(open, {
dirty,
});
expect(beforeRouteUpdateHandler).not.toBeNull();
const allowed = await beforeRouteUpdateHandler?.();
expect(allowed).toBe(false);
expect(confirmDialogMock).toHaveBeenCalledTimes(1);
expect(open.value).toBe(true);
});
it('only prevents browser unload while the editor is open and dirty, then removes the listener on unmount', () => {
const open = ref(true);
const dirty = ref(true);
useDirtyCloseGuard(open, {
dirty,
});
const guardedEvent = new window.Event('beforeunload', { cancelable: true });
const guardedResult = window.dispatchEvent(guardedEvent);
expect(guardedResult).toBe(false);
expect(guardedEvent.defaultPrevented).toBe(true);
dirty.value = false;
const cleanEvent = new window.Event('beforeunload', { cancelable: true });
const cleanResult = window.dispatchEvent(cleanEvent);
expect(cleanResult).toBe(true);
expect(cleanEvent.defaultPrevented).toBe(false);
for (const handler of unmountHandlers) {
handler();
}
unmountHandlers = [];
dirty.value = true;
const afterUnmountEvent = new window.Event('beforeunload', { cancelable: true });
const afterUnmountResult = window.dispatchEvent(afterUnmountEvent);
expect(afterUnmountResult).toBe(true);
expect(afterUnmountEvent.defaultPrevented).toBe(false);
});
it('dedupes concurrent close requests into one discard dialog', async () => {
const open = ref(true);
const dirty = ref(true);
const onDiscard = mock(() => {});
let resolveDialog: (result: DialogResult | PromiseLike<DialogResult>) => void = () => {
throw new Error('Expected the discard dialog promise to be pending.');
};
confirmDialogMock.mockImplementationOnce(
() =>
new Promise<DialogResult>((resolve) => {
resolveDialog = resolve;
}),
);
const guard = useDirtyCloseGuard(open, {
dirty,
onDiscard,
});
const first = guard.requestClose();
const second = guard.requestClose();
expect(confirmDialogMock).toHaveBeenCalledTimes(1);
resolveDialog({ status: true, value: null });
expect(await first).toBe(true);
expect(await second).toBe(true);
expect(onDiscard).toHaveBeenCalledTimes(1);
expect(open.value).toBe(false);
});
it('does not guard route changes when the editor is clean', async () => {
const open = ref(true);
const dirty = ref(false);
useDirtyCloseGuard(open, {
dirty,
});
expect(await beforeRouteLeaveHandler?.()).toBe(true);
expect(await beforeRouteUpdateHandler?.()).toBe(true);
expect(confirmDialogMock).not.toHaveBeenCalled();
expect(open.value).toBe(true);
});
});