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<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void;
|
(e: 'cancel'): void;
|
||||||
|
(e: 'dirty-change', dirty: boolean): void;
|
||||||
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
|
(e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
@ -557,6 +558,16 @@ const testData = ref<{
|
||||||
const showOptions = ref(false);
|
const showOptions = ref(false);
|
||||||
const ytDlpOpt = ref<AutoCompleteOptions>([]);
|
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 = {
|
const fieldUi = {
|
||||||
label: 'font-semibold text-default',
|
label: 'font-semibold text-default',
|
||||||
container: 'space-y-2',
|
container: 'space-y-2',
|
||||||
|
|
@ -586,10 +597,20 @@ watch(
|
||||||
() => props.item,
|
() => props.item,
|
||||||
(value) => {
|
(value) => {
|
||||||
Object.assign(form, normalizeCondition(value));
|
Object.assign(form, normalizeCondition(value));
|
||||||
|
|
||||||
|
importString.value = '';
|
||||||
|
newExtraKey.value = '';
|
||||||
|
newExtraValue.value = '';
|
||||||
|
nextTick(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => config.ytdlp_options,
|
() => config.ytdlp_options,
|
||||||
(newOptions) =>
|
(newOptions) =>
|
||||||
|
|
@ -855,4 +876,9 @@ const updateExtraValue = (key: string, rawValue: string): void => {
|
||||||
[key]: rawValue.trim() ? parseValue(rawValue.trim()) : '',
|
[key]: rawValue.trim() ? parseValue(rawValue.trim()) : '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,7 @@ import { decode } from '~/utils';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void;
|
(e: 'cancel'): void;
|
||||||
|
(e: 'dirty-change', dirty: boolean): void;
|
||||||
(e: 'submit', payload: { reference: number | null | undefined; item: DLField }): 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 showImport = useStorage('showDlFieldsImport', false);
|
||||||
const importString = ref('');
|
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 = {
|
const fieldUi = {
|
||||||
label: 'font-semibold text-default',
|
label: 'font-semibold text-default',
|
||||||
container: 'space-y-2',
|
container: 'space-y-2',
|
||||||
|
|
@ -288,10 +297,18 @@ watch(
|
||||||
() => props.item,
|
() => props.item,
|
||||||
(value) => {
|
(value) => {
|
||||||
Object.assign(form, normalizeField(value));
|
Object.assign(form, normalizeField(value));
|
||||||
|
|
||||||
|
importString.value = '';
|
||||||
|
nextTick(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => config.ytdlp_options,
|
() => config.ytdlp_options,
|
||||||
(newOptions) =>
|
(newOptions) =>
|
||||||
|
|
@ -400,4 +417,9 @@ const checkInfo = (): void => {
|
||||||
|
|
||||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,7 @@ import type { notification, notificationRequestHeaderItem } from '~/types/notifi
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(event: 'cancel'): void;
|
(event: 'cancel'): void;
|
||||||
|
(event: 'dirty-change', dirty: boolean): void;
|
||||||
(event: 'submit', payload: { reference: number | undefined; item: notification }): 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 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(
|
watch(
|
||||||
() => props.item,
|
() => props.item,
|
||||||
(value) => {
|
(value) => {
|
||||||
Object.assign(form, normalizeNotification(value));
|
Object.assign(form, normalizeNotification(value));
|
||||||
|
|
||||||
|
importString.value = '';
|
||||||
|
nextTick(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||||
|
|
||||||
function createDefaultNotification(): notification {
|
function createDefaultNotification(): notification {
|
||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -634,4 +651,9 @@ const importItem = async (): Promise<void> => {
|
||||||
toast.error(`Failed to import task. ${error.message}`);
|
toast.error(`Failed to import task. ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@ import { normalizePresetName } from '~/utils';
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(event: 'cancel'): void;
|
(event: 'cancel'): void;
|
||||||
|
(event: 'dirty-change', dirty: boolean): void;
|
||||||
(event: 'submit', payload: { reference: number | null; preset: Preset }): void;
|
(event: 'submit', payload: { reference: number | null; preset: Preset }): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
@ -420,6 +421,15 @@ const textareaUi = {
|
||||||
|
|
||||||
const importPresetItems = computed(() => selectItems.value);
|
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(
|
watch(
|
||||||
() => props.preset,
|
() => props.preset,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|
@ -434,10 +444,19 @@ watch(
|
||||||
priority: 0,
|
priority: 0,
|
||||||
...JSON.parse(JSON.stringify(value || {})),
|
...JSON.parse(JSON.stringify(value || {})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
importString.value = '';
|
||||||
|
selectedPreset.value = '';
|
||||||
|
nextTick(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(isDirty, (value: boolean) => emitter('dirty-change', value));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => config.ytdlp_options,
|
() => config.ytdlp_options,
|
||||||
(newOptions) =>
|
(newOptions) =>
|
||||||
|
|
@ -625,4 +644,9 @@ const importExistingPreset = async (): Promise<void> => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
selectedPreset.value = '';
|
selectedPreset.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -622,6 +622,7 @@ const props = defineProps<{
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'submit', payload: TaskDefinitionDocument): void;
|
(e: 'submit', payload: TaskDefinitionDocument): void;
|
||||||
(e: 'cancel'): void;
|
(e: 'cancel'): void;
|
||||||
|
(e: 'dirty-change', dirty: boolean): void;
|
||||||
(e: 'import-existing', id: number): 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 => {
|
const resetGuiState = (state: GuiState): void => {
|
||||||
guiState.name = state.name;
|
guiState.name = state.name;
|
||||||
guiState.priority = state.priority;
|
guiState.priority = state.priority;
|
||||||
|
|
@ -1034,6 +1045,10 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
|
||||||
containerSelector: '',
|
containerSelector: '',
|
||||||
fields: [defaultField()],
|
fields: [defaultField()],
|
||||||
});
|
});
|
||||||
|
nextTick(() => {
|
||||||
|
markClean();
|
||||||
|
emit('dirty-change', false);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1057,6 +1072,11 @@ const applyDocument = (document: TaskDefinitionDocument | null): void => {
|
||||||
mode.value = 'advanced';
|
mode.value = 'advanced';
|
||||||
errorMessage.value = 'Failed to prepare definition for editing.';
|
errorMessage.value = 'Failed to prepare definition for editing.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
markClean();
|
||||||
|
emit('dirty-change', false);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFromString = (): void => {
|
const importFromString = (): void => {
|
||||||
|
|
@ -1095,6 +1115,8 @@ watch(
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(isDirty, (value: boolean) => emit('dirty-change', value));
|
||||||
|
|
||||||
const switchMode = (next: EditorMode): void => {
|
const switchMode = (next: EditorMode): void => {
|
||||||
if (isBusy.value || next === mode.value) {
|
if (isBusy.value || next === mode.value) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,7 @@ const props = defineProps<{
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void;
|
(e: 'cancel'): void;
|
||||||
|
(e: 'dirty-change', dirty: boolean): void;
|
||||||
(
|
(
|
||||||
e: 'submit',
|
e: 'submit',
|
||||||
payload: { reference: number | null | undefined; task: Task | Task[]; archive_all?: boolean },
|
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 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 = {
|
const fieldUi = {
|
||||||
label: 'font-semibold text-default',
|
label: 'font-semibold text-default',
|
||||||
container: 'space-y-2',
|
container: 'space-y-2',
|
||||||
|
|
@ -611,6 +621,13 @@ watch(
|
||||||
if (!value?.preset) {
|
if (!value?.preset) {
|
||||||
form.preset = toRaw(config.app.default_preset);
|
form.preset = toRaw(config.app.default_preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import_string.value = '';
|
||||||
|
archiveAllAfterAdd.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true },
|
{ 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 handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
|
||||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||||
const isTextarea = target.tagName === 'TEXTAREA';
|
const isTextarea = target.tagName === 'TEXTAREA';
|
||||||
|
|
@ -931,4 +950,9 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
|
||||||
|
|
||||||
return getPresetDefault(form.preset, type, ret);
|
return getPresetDefault(form.preset, type, ret);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
markClean();
|
||||||
|
emitter('dirty-change', false);
|
||||||
|
});
|
||||||
</script>
|
</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 { computed, ref } from 'vue';
|
||||||
|
|
||||||
import type { Preset } from '~/types/presets';
|
import type { Preset } from '~/types/presets';
|
||||||
|
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||||
import { cleanObject, prettyName } from '~/utils';
|
import { cleanObject, prettyName } from '~/utils';
|
||||||
|
|
||||||
type EditablePreset = Partial<Preset> & {
|
type EditablePreset = Partial<Preset> & {
|
||||||
|
|
@ -34,14 +35,31 @@ const sanitizePreset = (item: Preset | EditablePreset): Partial<Preset> => {
|
||||||
|
|
||||||
export const usePresetEditor = () => {
|
export const usePresetEditor = () => {
|
||||||
const presetsStore = usePresets();
|
const presetsStore = usePresets();
|
||||||
const dialog = useDialog();
|
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
const reference = ref<number | null>(null);
|
const reference = ref<number | null>(null);
|
||||||
const preset = ref<Partial<Preset>>(makeEmptyPreset());
|
const preset = ref<Partial<Preset>>(makeEmptyPreset());
|
||||||
const initialSnapshot = ref('');
|
const dirty = ref(false);
|
||||||
const sessionId = ref(0);
|
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(() => {
|
const modalTitle = computed(() => {
|
||||||
return reference.value ? `Edit - ${prettyName(preset.value.name || '')}` : 'Add';
|
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 modalKey = computed(() => `${reference.value ?? 'create'}:${sessionId.value}`);
|
||||||
|
|
||||||
const reset = (): void => {
|
const reset = (): void => {
|
||||||
reference.value = null;
|
discardEditor();
|
||||||
preset.value = makeEmptyPreset();
|
|
||||||
initialSnapshot.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = (): void => {
|
const close = (): void => {
|
||||||
|
dirty.value = false;
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
reset();
|
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 => {
|
const openCreate = (): void => {
|
||||||
reset();
|
reset();
|
||||||
|
dirty.value = false;
|
||||||
sessionId.value += 1;
|
sessionId.value += 1;
|
||||||
initialSnapshot.value = snapshot(preset.value);
|
|
||||||
isOpen.value = true;
|
isOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: Preset | EditablePreset): void => {
|
const openEdit = (item: Preset | EditablePreset): void => {
|
||||||
|
dirty.value = false;
|
||||||
reference.value = item.id ?? null;
|
reference.value = item.id ?? null;
|
||||||
preset.value = sanitizePreset(item);
|
preset.value = sanitizePreset(item);
|
||||||
sessionId.value += 1;
|
sessionId.value += 1;
|
||||||
initialSnapshot.value = snapshot(preset.value);
|
|
||||||
isOpen.value = true;
|
isOpen.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,22 +100,7 @@ export const usePresetEditor = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDirty.value) {
|
await requestCloseGuard();
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async ({
|
const submit = async ({
|
||||||
|
|
@ -144,10 +135,12 @@ export const usePresetEditor = () => {
|
||||||
modalTitle,
|
modalTitle,
|
||||||
modalDescription,
|
modalDescription,
|
||||||
isDirty,
|
isDirty,
|
||||||
|
dirty,
|
||||||
addInProgress: presetsStore.addInProgress,
|
addInProgress: presetsStore.addInProgress,
|
||||||
openCreate,
|
openCreate,
|
||||||
openEdit,
|
openEdit,
|
||||||
close,
|
close,
|
||||||
|
handleOpenChange,
|
||||||
requestClose,
|
requestClose,
|
||||||
reset,
|
reset,
|
||||||
submit,
|
submit,
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@
|
||||||
:description="modalDescription"
|
:description="modalDescription"
|
||||||
:dismissible="!conditions.addInProgress.value"
|
:dismissible="!conditions.addInProgress.value"
|
||||||
:ui="{ content: 'w-full sm:max-w-6xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
: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>
|
<template #body>
|
||||||
<ConditionForm
|
<ConditionForm
|
||||||
|
|
@ -467,7 +467,8 @@
|
||||||
:addInProgress="conditions.addInProgress.value"
|
:addInProgress="conditions.addInProgress.value"
|
||||||
:reference="itemRef"
|
:reference="itemRef"
|
||||||
:item="item as Condition"
|
:item="item as Condition"
|
||||||
@cancel="closeEditor()"
|
@cancel="() => void requestCloseEditor()"
|
||||||
|
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||||
@submit="updateItem"
|
@submit="updateItem"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -504,6 +505,7 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
|
||||||
const item = ref<Partial<Condition>>({});
|
const item = ref<Partial<Condition>>({});
|
||||||
const itemRef = ref<number | null | undefined>(null);
|
const itemRef = ref<number | null | undefined>(null);
|
||||||
const editorOpen = ref(false);
|
const editorOpen = ref(false);
|
||||||
|
const editorDirty = ref(false);
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
const showFilter = ref(false);
|
const showFilter = ref(false);
|
||||||
const filterInput = ref<HTMLInputElement | null>(null);
|
const filterInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
@ -555,6 +557,21 @@ const modalKey = computed(
|
||||||
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
() => `${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) => {
|
watch(showFilter, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
query.value = '';
|
query.value = '';
|
||||||
|
|
@ -614,6 +631,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
|
||||||
const resetEditor = (): void => {
|
const resetEditor = (): void => {
|
||||||
item.value = {};
|
item.value = {};
|
||||||
itemRef.value = null;
|
itemRef.value = null;
|
||||||
|
editorDirty.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = (): void => {
|
const closeEditor = (): void => {
|
||||||
|
|
@ -721,6 +739,7 @@ const updateItem = async ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (value: Condition): void => {
|
const editItem = (value: Condition): void => {
|
||||||
|
editorDirty.value = false;
|
||||||
item.value = JSON.parse(JSON.stringify(value)) as Condition;
|
item.value = JSON.parse(JSON.stringify(value)) as Condition;
|
||||||
itemRef.value = value.id;
|
itemRef.value = value.id;
|
||||||
editorOpen.value = true;
|
editorOpen.value = true;
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,7 @@
|
||||||
:description="modalDescription"
|
:description="modalDescription"
|
||||||
:dismissible="!dlFields.addInProgress.value"
|
:dismissible="!dlFields.addInProgress.value"
|
||||||
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
: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>
|
<template #body>
|
||||||
<DLFieldForm
|
<DLFieldForm
|
||||||
|
|
@ -390,7 +390,8 @@
|
||||||
:addInProgress="dlFields.addInProgress.value"
|
:addInProgress="dlFields.addInProgress.value"
|
||||||
:reference="itemRef"
|
:reference="itemRef"
|
||||||
:item="item as DLField"
|
:item="item as DLField"
|
||||||
@cancel="closeEditor()"
|
@cancel="() => void requestCloseEditor()"
|
||||||
|
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||||
@submit="updateItem"
|
@submit="updateItem"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -425,6 +426,7 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
|
||||||
const item = ref<Partial<DLField>>({});
|
const item = ref<Partial<DLField>>({});
|
||||||
const itemRef = ref<number | null | undefined>(null);
|
const itemRef = ref<number | null | undefined>(null);
|
||||||
const editorOpen = ref(false);
|
const editorOpen = ref(false);
|
||||||
|
const editorDirty = ref(false);
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
const showFilter = ref(false);
|
const showFilter = ref(false);
|
||||||
const filterInput = ref<HTMLInputElement | null>(null);
|
const filterInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
@ -472,6 +474,21 @@ const modalKey = computed(
|
||||||
() => `${itemRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
() => `${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) => {
|
watch(showFilter, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
query.value = '';
|
query.value = '';
|
||||||
|
|
@ -531,6 +548,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
|
||||||
const resetEditor = (): void => {
|
const resetEditor = (): void => {
|
||||||
item.value = {};
|
item.value = {};
|
||||||
itemRef.value = null;
|
itemRef.value = null;
|
||||||
|
editorDirty.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = (): void => {
|
const closeEditor = (): void => {
|
||||||
|
|
@ -629,6 +647,7 @@ const updateItem = async ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (field: DLField): void => {
|
const editItem = (field: DLField): void => {
|
||||||
|
editorDirty.value = false;
|
||||||
item.value = JSON.parse(JSON.stringify(field)) as DLField;
|
item.value = JSON.parse(JSON.stringify(field)) as DLField;
|
||||||
itemRef.value = field.id;
|
itemRef.value = field.id;
|
||||||
editorOpen.value = true;
|
editorOpen.value = true;
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,7 @@
|
||||||
:description="modalDescription"
|
:description="modalDescription"
|
||||||
:dismissible="!addInProgress"
|
:dismissible="!addInProgress"
|
||||||
:ui="{ content: 'w-full sm:max-w-5xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
: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>
|
<template #body>
|
||||||
<NotificationForm
|
<NotificationForm
|
||||||
|
|
@ -521,7 +521,8 @@
|
||||||
:reference="targetRef"
|
:reference="targetRef"
|
||||||
:item="target"
|
:item="target"
|
||||||
:allowedEvents="allowedEvents"
|
:allowedEvents="allowedEvents"
|
||||||
@cancel="closeEditor()"
|
@cancel="() => void requestCloseEditor()"
|
||||||
|
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||||
@submit="updateItem"
|
@submit="updateItem"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -562,6 +563,7 @@ const page = ref(1);
|
||||||
const targetRef = ref<number | undefined>(undefined);
|
const targetRef = ref<number | undefined>(undefined);
|
||||||
const target = ref<notification>(defaultState());
|
const target = ref<notification>(defaultState());
|
||||||
const editorOpen = ref(false);
|
const editorOpen = ref(false);
|
||||||
|
const editorDirty = ref(false);
|
||||||
const sendingTest = ref(false);
|
const sendingTest = ref(false);
|
||||||
const query = ref('');
|
const query = ref('');
|
||||||
const showFilter = ref(false);
|
const showFilter = ref(false);
|
||||||
|
|
@ -583,6 +585,21 @@ const modalKey = computed(
|
||||||
() => `${targetRef.value ?? 'new'}-${editorOpen.value ? 'open' : 'closed'}`,
|
() => `${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 filteredTargets = computed<notification[]>(() => {
|
||||||
const normalizedQuery = query.value?.toLowerCase();
|
const normalizedQuery = query.value?.toLowerCase();
|
||||||
const items = notifications.value as notification[];
|
const items = notifications.value as notification[];
|
||||||
|
|
@ -672,6 +689,7 @@ const navigatePage = async (newPage: number): Promise<void> => {
|
||||||
const resetEditor = (): void => {
|
const resetEditor = (): void => {
|
||||||
target.value = defaultState();
|
target.value = defaultState();
|
||||||
targetRef.value = undefined;
|
targetRef.value = undefined;
|
||||||
|
editorDirty.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = (): void => {
|
const closeEditor = (): void => {
|
||||||
|
|
@ -694,6 +712,7 @@ const toggleMasterSelection = (): void => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (item: notification): void => {
|
const editItem = (item: notification): void => {
|
||||||
|
editorDirty.value = false;
|
||||||
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
target.value = JSON.parse(JSON.stringify(item)) as notification;
|
||||||
targetRef.value = item.id ?? undefined;
|
targetRef.value = item.id ?? undefined;
|
||||||
editorOpen.value = true;
|
editorOpen.value = true;
|
||||||
|
|
|
||||||
|
|
@ -424,7 +424,7 @@
|
||||||
:description="editor.modalDescription.value"
|
:description="editor.modalDescription.value"
|
||||||
:dismissible="!editor.addInProgress.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' }"
|
: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>
|
<template #body>
|
||||||
<PresetForm
|
<PresetForm
|
||||||
|
|
@ -434,6 +434,7 @@
|
||||||
:preset="editor.preset.value"
|
:preset="editor.preset.value"
|
||||||
:presets="presets"
|
:presets="presets"
|
||||||
@cancel="() => void editor.requestClose()"
|
@cancel="() => void editor.requestClose()"
|
||||||
|
@dirty-change="(dirty) => (editor.dirty.value = dirty)"
|
||||||
@submit="editor.submit"
|
@submit="editor.submit"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,7 @@
|
||||||
:description="editorDescription"
|
:description="editorDescription"
|
||||||
:dismissible="!editorLoading && !editorSubmitting"
|
:dismissible="!editorLoading && !editorSubmitting"
|
||||||
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
: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>
|
<template #body>
|
||||||
<TaskDefinitionEditor
|
<TaskDefinitionEditor
|
||||||
|
|
@ -414,7 +414,8 @@
|
||||||
:loading="editorLoading"
|
:loading="editorLoading"
|
||||||
:submitting="editorSubmitting"
|
:submitting="editorSubmitting"
|
||||||
@submit="submitDefinition"
|
@submit="submitDefinition"
|
||||||
@cancel="closeEditor"
|
@cancel="() => void requestCloseEditor()"
|
||||||
|
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||||
@import-existing="importExistingDefinition"
|
@import-existing="importExistingDefinition"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -491,6 +492,7 @@ const definitions = computed<TaskDefinitionSummary[]>(() => [...definitionsRef.v
|
||||||
const { confirmDialog } = useDialog();
|
const { confirmDialog } = useDialog();
|
||||||
|
|
||||||
const isEditorOpen = ref(false);
|
const isEditorOpen = ref(false);
|
||||||
|
const editorDirty = ref(false);
|
||||||
const editorMode = ref<'create' | 'edit'>('create');
|
const editorMode = ref<'create' | 'edit'>('create');
|
||||||
const editorLoading = ref(false);
|
const editorLoading = ref(false);
|
||||||
const editorSubmitting = ref(false);
|
const editorSubmitting = ref(false);
|
||||||
|
|
@ -578,6 +580,24 @@ const showImportByDefault = computed(
|
||||||
() => editorMode.value === 'create' && !hideImportByDefault.value,
|
() => 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) => {
|
watch(showFilter, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
query.value = '';
|
query.value = '';
|
||||||
|
|
@ -628,6 +648,7 @@ const toggleMasterSelection = (): void => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreate = (): void => {
|
const openCreate = (): void => {
|
||||||
|
editorDirty.value = false;
|
||||||
editorMode.value = 'create';
|
editorMode.value = 'create';
|
||||||
workingId.value = null;
|
workingId.value = null;
|
||||||
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
|
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
|
||||||
|
|
@ -638,6 +659,7 @@ const openCreate = (): void => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
||||||
|
editorDirty.value = false;
|
||||||
editorMode.value = 'edit';
|
editorMode.value = 'edit';
|
||||||
workingId.value = summary.id;
|
workingId.value = summary.id;
|
||||||
workingDefinition.value = null;
|
workingDefinition.value = null;
|
||||||
|
|
@ -668,6 +690,7 @@ const importExistingDefinition = async (id: number): Promise<void> => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editorDirty.value = false;
|
||||||
editorMode.value = 'create';
|
editorMode.value = 'create';
|
||||||
workingId.value = null;
|
workingId.value = null;
|
||||||
workingDefinition.value = {
|
workingDefinition.value = {
|
||||||
|
|
@ -687,6 +710,7 @@ const closeEditor = (): void => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editorDirty.value = false;
|
||||||
isEditorOpen.value = false;
|
isEditorOpen.value = false;
|
||||||
workingDefinition.value = null;
|
workingDefinition.value = null;
|
||||||
workingId.value = null;
|
workingId.value = null;
|
||||||
|
|
|
||||||
|
|
@ -648,7 +648,7 @@
|
||||||
:description="editorDescription"
|
:description="editorDescription"
|
||||||
:dismissible="!addInProgress"
|
:dismissible="!addInProgress"
|
||||||
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
: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>
|
<template #body>
|
||||||
<TaskForm
|
<TaskForm
|
||||||
|
|
@ -656,7 +656,8 @@
|
||||||
:addInProgress="addInProgress"
|
:addInProgress="addInProgress"
|
||||||
:reference="taskRef"
|
:reference="taskRef"
|
||||||
:task="task as Task"
|
:task="task as Task"
|
||||||
@cancel="closeEditor"
|
@cancel="() => void requestCloseEditor()"
|
||||||
|
@dirty-change="(dirty) => (editorDirty = dirty)"
|
||||||
@submit="updateItem"
|
@submit="updateItem"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -729,6 +730,7 @@ const createEmptyTask = (): Partial<Task> => ({
|
||||||
const task = ref<Partial<Task>>(createEmptyTask());
|
const task = ref<Partial<Task>>(createEmptyTask());
|
||||||
const taskRef = ref<number | null>(null);
|
const taskRef = ref<number | null>(null);
|
||||||
const toggleForm = ref(false);
|
const toggleForm = ref(false);
|
||||||
|
const editorDirty = ref(false);
|
||||||
const selectedElms = ref<number[]>([]);
|
const selectedElms = ref<number[]>([]);
|
||||||
const massRun = ref(false);
|
const massRun = ref(false);
|
||||||
const massDelete = ref(false);
|
const massDelete = ref(false);
|
||||||
|
|
@ -757,6 +759,21 @@ const editorDescription = computed(() => {
|
||||||
|
|
||||||
const formKey = computed(() => `${taskRef.value ?? 'new'}:${editorSessionId.value}`);
|
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 filteredTasks = computed(() => {
|
||||||
const normalizedQuery = query.value?.toLowerCase();
|
const normalizedQuery = query.value?.toLowerCase();
|
||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
|
|
@ -925,6 +942,7 @@ const reloadContent = async (fromMounted: boolean = false) => {
|
||||||
const resetForm = (closeForm: boolean = false) => {
|
const resetForm = (closeForm: boolean = false) => {
|
||||||
task.value = createEmptyTask();
|
task.value = createEmptyTask();
|
||||||
taskRef.value = null;
|
taskRef.value = null;
|
||||||
|
editorDirty.value = false;
|
||||||
|
|
||||||
if (closeForm) {
|
if (closeForm) {
|
||||||
toggleForm.value = false;
|
toggleForm.value = false;
|
||||||
|
|
@ -1068,6 +1086,7 @@ const updateItem = async ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const editItem = (item: Task) => {
|
const editItem = (item: Task) => {
|
||||||
|
editorDirty.value = false;
|
||||||
task.value = { ...item };
|
task.value = { ...item };
|
||||||
taskRef.value = item.id ?? null;
|
taskRef.value = item.id ?? null;
|
||||||
editorSessionId.value += 1;
|
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