feat: add form guards to prevent accidental data loss
This commit is contained in:
parent
360d2f3df8
commit
182a2e97e6
16 changed files with 582 additions and 45 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
105
ui/app/composables/useDirtyCloseGuard.ts
Normal file
105
ui/app/composables/useDirtyCloseGuard.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
20
ui/app/composables/useDirtyState.ts
Normal file
20
ui/app/composables/useDirtyState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
178
ui/tests/composables/useDirtyCloseGuard.test.ts
Normal file
178
ui/tests/composables/useDirtyCloseGuard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue