diff --git a/ui/app/components/ConditionForm.vue b/ui/app/components/ConditionForm.vue index 7d8fcf8d..52173fab 100644 --- a/ui/app/components/ConditionForm.vue +++ b/ui/app/components/ConditionForm.vue @@ -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([]); +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); +}); diff --git a/ui/app/components/DLFieldForm.vue b/ui/app/components/DLFieldForm.vue index b5a7c042..8b99971b 100644 --- a/ui/app/components/DLFieldForm.vue +++ b/ui/app/components/DLFieldForm.vue @@ -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([]); 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); +}); diff --git a/ui/app/components/NotificationForm.vue b/ui/app/components/NotificationForm.vue index 9ddec747..d2b0abb0 100644 --- a/ui/app/components/NotificationForm.vue +++ b/ui/app/components/NotificationForm.vue @@ -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 => { toast.error(`Failed to import task. ${error.message}`); } }; + +onMounted(() => { + markClean(); + emitter('dirty-change', false); +}); diff --git a/ui/app/components/PresetForm.vue b/ui/app/components/PresetForm.vue index 4175b6e1..690d1de1 100644 --- a/ui/app/components/PresetForm.vue +++ b/ui/app/components/PresetForm.vue @@ -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 => { await nextTick(); selectedPreset.value = ''; }; + +onMounted(() => { + markClean(); + emitter('dirty-change', false); +}); diff --git a/ui/app/components/TaskDefinitionEditor.vue b/ui/app/components/TaskDefinitionEditor.vue index 05230da2..340b140b 100644 --- a/ui/app/components/TaskDefinitionEditor.vue +++ b/ui/app/components/TaskDefinitionEditor.vue @@ -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; diff --git a/ui/app/components/TaskForm.vue b/ui/app/components/TaskForm.vue index 5668b77f..610d43d4 100644 --- a/ui/app/components/TaskForm.vue +++ b/ui/app/components/TaskForm.vue @@ -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(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 => { 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); +}); diff --git a/ui/app/composables/useDirtyCloseGuard.ts b/ui/app/composables/useDirtyCloseGuard.ts new file mode 100644 index 00000000..6eff0a33 --- /dev/null +++ b/ui/app/composables/useDirtyCloseGuard.ts @@ -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; + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + confirmColor?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'; + onDiscard?: () => void | Promise; +}; + +export const useDirtyCloseGuard = (open: Ref, options: DirtyCloseGuardOptions) => { + const dialog = useDialog(); + let pendingCloseRequest: Promise | null = null; + + const isDirty = computed(() => Boolean(toValue(options.dirty))); + const shouldGuard = computed(() => Boolean(open.value) && true === isDirty.value); + + const confirmClose = async (): Promise => { + 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 => { + if (pendingCloseRequest) { + return pendingCloseRequest; + } + + pendingCloseRequest = confirmClose().finally(() => { + pendingCloseRequest = null; + }); + + return pendingCloseRequest; + }; + + const handleOpenChange = async (value: boolean): Promise => { + 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, + }; +}; diff --git a/ui/app/composables/useDirtyState.ts b/ui/app/composables/useDirtyState.ts new file mode 100644 index 00000000..153dc8b7 --- /dev/null +++ b/ui/app/composables/useDirtyState.ts @@ -0,0 +1,20 @@ +import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue'; + +const serializeDirtyValue = (value: T): string => JSON.stringify(toValue(value)); + +export const useDirtyState = (source: MaybeRefOrGetter) => { + const snapshot = ref(''); + + const markClean = (): void => { + snapshot.value = serializeDirtyValue(toValue(source)); + }; + + const isDirty = computed(() => { + return snapshot.value !== serializeDirtyValue(toValue(source)); + }); + + return { + isDirty, + markClean, + }; +}; diff --git a/ui/app/composables/usePresetEditor.ts b/ui/app/composables/usePresetEditor.ts index 13743224..b125fb14 100644 --- a/ui/app/composables/usePresetEditor.ts +++ b/ui/app/composables/usePresetEditor.ts @@ -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 & { @@ -34,14 +35,31 @@ const sanitizePreset = (item: Preset | EditablePreset): Partial => { export const usePresetEditor = () => { const presetsStore = usePresets(); - const dialog = useDialog(); const isOpen = ref(false); const reference = ref(null); const preset = ref>(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): 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, diff --git a/ui/app/pages/conditions.vue b/ui/app/pages/conditions.vue index d6e88a04..70765da2 100644 --- a/ui/app/pages/conditions.vue +++ b/ui/app/pages/conditions.vue @@ -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" > @@ -504,6 +505,7 @@ const page = ref(route.query.page ? parseInt(route.query.page as string, const item = ref>({}); const itemRef = ref(null); const editorOpen = ref(false); +const editorDirty = ref(false); const query = ref(''); const showFilter = ref(false); const filterInput = ref(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 => { 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; diff --git a/ui/app/pages/dl_fields.vue b/ui/app/pages/dl_fields.vue index d972b13d..4adbaebd 100644 --- a/ui/app/pages/dl_fields.vue +++ b/ui/app/pages/dl_fields.vue @@ -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" > @@ -425,6 +426,7 @@ const page = ref(route.query.page ? parseInt(route.query.page as string, const item = ref>({}); const itemRef = ref(null); const editorOpen = ref(false); +const editorDirty = ref(false); const query = ref(''); const showFilter = ref(false); const filterInput = ref(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 => { 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; diff --git a/ui/app/pages/notifications.vue b/ui/app/pages/notifications.vue index 8da53c94..5494b4fd 100644 --- a/ui/app/pages/notifications.vue +++ b/ui/app/pages/notifications.vue @@ -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" > @@ -562,6 +563,7 @@ const page = ref(1); const targetRef = ref(undefined); const target = ref(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(() => { const normalizedQuery = query.value?.toLowerCase(); const items = notifications.value as notification[]; @@ -672,6 +689,7 @@ const navigatePage = async (newPage: number): Promise => { 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; diff --git a/ui/app/pages/presets.vue b/ui/app/pages/presets.vue index 8aa10d06..ef73a328 100644 --- a/ui/app/pages/presets.vue +++ b/ui/app/pages/presets.vue @@ -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)" > diff --git a/ui/app/pages/task_definitions.vue b/ui/app/pages/task_definitions.vue index 72c601a1..1472fcde 100644 --- a/ui/app/pages/task_definitions.vue +++ b/ui/app/pages/task_definitions.vue @@ -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" > @@ -491,6 +492,7 @@ const definitions = computed(() => [...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 => { + editorDirty.value = false; editorMode.value = 'edit'; workingId.value = summary.id; workingDefinition.value = null; @@ -668,6 +690,7 @@ const importExistingDefinition = async (id: number): Promise => { 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; diff --git a/ui/app/pages/tasks.vue b/ui/app/pages/tasks.vue index f930a824..87d4dc1c 100644 --- a/ui/app/pages/tasks.vue +++ b/ui/app/pages/tasks.vue @@ -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" > @@ -729,6 +730,7 @@ const createEmptyTask = (): Partial => ({ const task = ref>(createEmptyTask()); const taskRef = ref(null); const toggleForm = ref(false); +const editorDirty = ref(false); const selectedElms = ref([]); 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; diff --git a/ui/tests/composables/useDirtyCloseGuard.test.ts b/ui/tests/composables/useDirtyCloseGuard.test.ts new file mode 100644 index 00000000..67df57ed --- /dev/null +++ b/ui/tests/composables/useDirtyCloseGuard.test.ts @@ -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; + +let beforeRouteLeaveHandler: RouteGuard | null = null; +let beforeRouteUpdateHandler: RouteGuard | null = null; +let unmountHandlers: Array<() => void> = []; + +const confirmDialogMock = mock<() => Promise>(() => + 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) => void = () => { + throw new Error('Expected the discard dialog promise to be pending.'); + }; + confirmDialogMock.mockImplementationOnce( + () => + new Promise((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); + }); +});