feat(grid): support deleting custom column formatters
This commit is contained in:
11 files changed
+508
-77
No files matched your search
@@ -1076,8 +1076,13 @@ const formatterJsonPath = ref("$.user.name");
|
|||||||
const formatterMaskPrefix = ref(4);
|
const formatterMaskPrefix = ref(4);
|
||||||
const formatterMaskSuffix = ref(4);
|
const formatterMaskSuffix = ref(4);
|
||||||
const formatterCustomId = ref(CUSTOM_FORMATTER_NEW);
|
const formatterCustomId = ref(CUSTOM_FORMATTER_NEW);
|
||||||
|
const formatterCustomCapturedDeleteVersion = ref<number>();
|
||||||
const formatterCustomName = ref("");
|
const formatterCustomName = ref("");
|
||||||
const formatterCustomTemplate = ref("${value}");
|
const formatterCustomTemplate = ref("${value}");
|
||||||
|
const formatterCustomDeleteOpen = ref(false);
|
||||||
|
const formatterCustomDeleteLoading = ref(false);
|
||||||
|
const formatterCustomDeleteId = ref("");
|
||||||
|
const formatterCustomDeleteName = ref("");
|
||||||
const formatterForeignKeyRefSchema = ref("");
|
const formatterForeignKeyRefSchema = ref("");
|
||||||
const formatterForeignKeyRefTable = ref("");
|
const formatterForeignKeyRefTable = ref("");
|
||||||
const formatterForeignKeyRefColumn = ref("");
|
const formatterForeignKeyRefColumn = ref("");
|
||||||
@@ -1508,10 +1513,12 @@ function loadFormatterDraft(formatter: ColumnFormatterConfig | undefined) {
|
|||||||
} else if (draft.kind === "custom-ref") {
|
} else if (draft.kind === "custom-ref") {
|
||||||
const saved = settingsStore.editorSettings.customColumnFormatters[draft.formatterId];
|
const saved = settingsStore.editorSettings.customColumnFormatters[draft.formatterId];
|
||||||
formatterCustomId.value = saved ? saved.id : CUSTOM_FORMATTER_NEW;
|
formatterCustomId.value = saved ? saved.id : CUSTOM_FORMATTER_NEW;
|
||||||
|
formatterCustomCapturedDeleteVersion.value = saved ? settingsStore.customColumnFormatterDeleteVersion(saved.id) : undefined;
|
||||||
formatterCustomName.value = saved?.name ?? "";
|
formatterCustomName.value = saved?.name ?? "";
|
||||||
formatterCustomTemplate.value = saved?.template ?? "${value}";
|
formatterCustomTemplate.value = saved?.template ?? "${value}";
|
||||||
} else if (draft.kind === "custom-template") {
|
} else if (draft.kind === "custom-template") {
|
||||||
formatterCustomId.value = CUSTOM_FORMATTER_NEW;
|
formatterCustomId.value = CUSTOM_FORMATTER_NEW;
|
||||||
|
formatterCustomCapturedDeleteVersion.value = undefined;
|
||||||
formatterCustomName.value = "";
|
formatterCustomName.value = "";
|
||||||
formatterCustomTemplate.value = draft.template;
|
formatterCustomTemplate.value = draft.template;
|
||||||
} else if (draft.kind === "foreign-key-display") {
|
} else if (draft.kind === "foreign-key-display") {
|
||||||
@@ -1585,22 +1592,33 @@ function handleColumnFormatterOpenChange(value: boolean, columnIndex: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveColumnFormatter(columnIndex: number) {
|
async function saveColumnFormatter(columnIndex: number) {
|
||||||
const column = props.result.columns[columnIndex];
|
const column = props.result.columns[columnIndex];
|
||||||
const key = column ? formatterKeyForColumn(column) : null;
|
const key = column ? formatterKeyForColumn(column) : null;
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
let formatter = currentFormatterDraft();
|
try {
|
||||||
if (formatterKind.value === "custom-template" && formatterCustomName.value.trim()) {
|
let formatter = currentFormatterDraft();
|
||||||
const id = formatterCustomId.value === CUSTOM_FORMATTER_NEW ? createCustomFormatterId() : formatterCustomId.value;
|
if (formatterKind.value === "custom-template" && formatterCustomName.value.trim()) {
|
||||||
const saved = settingsStore.upsertCustomColumnFormatter({
|
const id = formatterCustomId.value === CUSTOM_FORMATTER_NEW ? createCustomFormatterId() : formatterCustomId.value;
|
||||||
id,
|
const saved = await settingsStore.upsertCustomColumnFormatter(
|
||||||
name: formatterCustomName.value,
|
{
|
||||||
template: formatterCustomTemplate.value,
|
id,
|
||||||
});
|
name: formatterCustomName.value,
|
||||||
if (saved) formatter = { kind: "custom-ref", formatterId: saved.id };
|
template: formatterCustomTemplate.value,
|
||||||
|
},
|
||||||
|
formatterCustomCapturedDeleteVersion.value,
|
||||||
|
);
|
||||||
|
if (!saved) {
|
||||||
|
selectCustomFormatter(CUSTOM_FORMATTER_NEW);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formatter = { kind: "custom-ref", formatterId: saved.id };
|
||||||
|
}
|
||||||
|
settingsStore.updateColumnFormatter(key, formatter);
|
||||||
|
closeColumnFormatter();
|
||||||
|
} catch (error) {
|
||||||
|
toast(t("grid.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
|
||||||
}
|
}
|
||||||
settingsStore.updateColumnFormatter(key, formatter);
|
|
||||||
closeColumnFormatter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearColumnFormatter(columnIndex: number) {
|
function clearColumnFormatter(columnIndex: number) {
|
||||||
@@ -1629,16 +1647,44 @@ function selectFormatterKind(value: FormatterDraftKind, columnIndex: number) {
|
|||||||
function selectCustomFormatter(value: string) {
|
function selectCustomFormatter(value: string) {
|
||||||
formatterCustomId.value = value;
|
formatterCustomId.value = value;
|
||||||
if (value === CUSTOM_FORMATTER_NEW) {
|
if (value === CUSTOM_FORMATTER_NEW) {
|
||||||
|
formatterCustomCapturedDeleteVersion.value = undefined;
|
||||||
formatterCustomName.value = "";
|
formatterCustomName.value = "";
|
||||||
formatterCustomTemplate.value = "${value}";
|
formatterCustomTemplate.value = "${value}";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const saved = settingsStore.editorSettings.customColumnFormatters[value];
|
const saved = settingsStore.editorSettings.customColumnFormatters[value];
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
|
formatterCustomCapturedDeleteVersion.value = settingsStore.customColumnFormatterDeleteVersion(saved.id);
|
||||||
formatterCustomName.value = saved.name;
|
formatterCustomName.value = saved.name;
|
||||||
formatterCustomTemplate.value = saved.template;
|
formatterCustomTemplate.value = saved.template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDeleteCustomFormatter() {
|
||||||
|
if (formatterCustomId.value === CUSTOM_FORMATTER_NEW) return;
|
||||||
|
const saved = settingsStore.editorSettings.customColumnFormatters[formatterCustomId.value];
|
||||||
|
if (!saved) return;
|
||||||
|
formatterCustomDeleteId.value = saved.id;
|
||||||
|
formatterCustomDeleteName.value = saved.name;
|
||||||
|
formatterCustomDeleteOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteCustomFormatter() {
|
||||||
|
const id = formatterCustomDeleteId.value;
|
||||||
|
if (!id || formatterCustomDeleteLoading.value) return;
|
||||||
|
formatterCustomDeleteLoading.value = true;
|
||||||
|
try {
|
||||||
|
await settingsStore.deleteCustomColumnFormatter(id);
|
||||||
|
if (formatterCustomId.value === id) selectCustomFormatter(CUSTOM_FORMATTER_NEW);
|
||||||
|
formatterCustomDeleteOpen.value = false;
|
||||||
|
formatterCustomDeleteId.value = "";
|
||||||
|
formatterCustomDeleteName.value = "";
|
||||||
|
} catch (error) {
|
||||||
|
toast(t("grid.tableOperationFailed", { message: translateBackendError(t, error) }), 5000);
|
||||||
|
} finally {
|
||||||
|
formatterCustomDeleteLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createCustomFormatterId(): string {
|
function createCustomFormatterId(): string {
|
||||||
return `fmt_${uuid()}`;
|
return `fmt_${uuid()}`;
|
||||||
}
|
}
|
||||||
@@ -11991,17 +12037,31 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
|||||||
<div class="text-xs font-medium text-muted-foreground">
|
<div class="text-xs font-medium text-muted-foreground">
|
||||||
{{ t("grid.formatterSavedCustom") }}
|
{{ t("grid.formatterSavedCustom") }}
|
||||||
</div>
|
</div>
|
||||||
<Select :model-value="formatterCustomId" @update:model-value="(value: any) => selectCustomFormatter(String(value))">
|
<div class="flex items-center gap-1">
|
||||||
<SelectTrigger class="h-8 text-xs">
|
<Select :model-value="formatterCustomId" @update:model-value="(value: any) => selectCustomFormatter(String(value))">
|
||||||
<SelectValue />
|
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs">
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem :value="CUSTOM_FORMATTER_NEW">{{ t("grid.formatterNewCustom") }}</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem v-for="formatter in savedCustomFormatters" :key="formatter.id" :value="formatter.id">
|
<SelectItem :value="CUSTOM_FORMATTER_NEW">{{ t("grid.formatterNewCustom") }}</SelectItem>
|
||||||
{{ formatter.name }}
|
<SelectItem v-for="formatter in savedCustomFormatters" :key="formatter.id" :value="formatter.id">
|
||||||
</SelectItem>
|
{{ formatter.name }}
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
:disabled="formatterCustomId === CUSTOM_FORMATTER_NEW"
|
||||||
|
:title="t('grid.formatterDeleteCustom')"
|
||||||
|
:aria-label="t('grid.formatterDeleteCustom')"
|
||||||
|
@click.stop="requestDeleteCustomFormatter"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="block space-y-1.5">
|
<label class="block space-y-1.5">
|
||||||
<span class="text-xs font-medium text-muted-foreground">
|
<span class="text-xs font-medium text-muted-foreground">
|
||||||
@@ -13140,6 +13200,15 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
|
|||||||
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
|
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DangerConfirmDialog
|
||||||
|
v-model:open="formatterCustomDeleteOpen"
|
||||||
|
:title="t('grid.formatterDeleteCustom')"
|
||||||
|
:message="t('grid.formatterDeleteCustomMessage', { name: formatterCustomDeleteName })"
|
||||||
|
:confirm-label="t('grid.formatterDeleteCustom')"
|
||||||
|
:loading="formatterCustomDeleteLoading"
|
||||||
|
:close-on-confirm="false"
|
||||||
|
@confirm="confirmDeleteCustomFormatter"
|
||||||
|
/>
|
||||||
<DangerConfirmDialog
|
<DangerConfirmDialog
|
||||||
v-model:open="conditionalBulkEditConfirmOpen"
|
v-model:open="conditionalBulkEditConfirmOpen"
|
||||||
:title="t('grid.conditionalBulkEditConfirmTitle')"
|
:title="t('grid.conditionalBulkEditConfirmTitle')"
|
||||||
|
|||||||
@@ -1728,6 +1728,8 @@ export default {
|
|||||||
formatterMaskSuffix: "Visible suffix",
|
formatterMaskSuffix: "Visible suffix",
|
||||||
formatterSavedCustom: "Saved templates",
|
formatterSavedCustom: "Saved templates",
|
||||||
formatterNewCustom: "New template",
|
formatterNewCustom: "New template",
|
||||||
|
formatterDeleteCustom: "Delete template",
|
||||||
|
formatterDeleteCustomMessage: 'Delete "{name}"? Formatting will be cleared from every column using this template.',
|
||||||
formatterCustomName: "Template name",
|
formatterCustomName: "Template name",
|
||||||
formatterCustomNamePlaceholder: "Example: User label",
|
formatterCustomNamePlaceholder: "Example: User label",
|
||||||
formatterCustomTemplateInput: "Template",
|
formatterCustomTemplateInput: "Template",
|
||||||
|
|||||||
@@ -1612,6 +1612,8 @@ export default withEnglishFallback({
|
|||||||
formatterMaskSuffix: "Sufijo visible",
|
formatterMaskSuffix: "Sufijo visible",
|
||||||
formatterSavedCustom: "Plantillas guardadas",
|
formatterSavedCustom: "Plantillas guardadas",
|
||||||
formatterNewCustom: "Nueva plantilla",
|
formatterNewCustom: "Nueva plantilla",
|
||||||
|
formatterDeleteCustom: "Eliminar plantilla",
|
||||||
|
formatterDeleteCustomMessage: '¿Eliminar "{name}"? Se quitará el formato de todas las columnas que usen esta plantilla.',
|
||||||
formatterCustomName: "Nombre de plantilla",
|
formatterCustomName: "Nombre de plantilla",
|
||||||
formatterCustomNamePlaceholder: "Ejemplo: Etiqueta de usuario",
|
formatterCustomNamePlaceholder: "Ejemplo: Etiqueta de usuario",
|
||||||
formatterCustomTemplateInput: "Plantilla",
|
formatterCustomTemplateInput: "Plantilla",
|
||||||
|
|||||||
@@ -1610,6 +1610,8 @@ export default withEnglishFallback({
|
|||||||
formatterMaskSuffix: "Suffisso visibile",
|
formatterMaskSuffix: "Suffisso visibile",
|
||||||
formatterSavedCustom: "Modelli salvati",
|
formatterSavedCustom: "Modelli salvati",
|
||||||
formatterNewCustom: "Nuovo modello",
|
formatterNewCustom: "Nuovo modello",
|
||||||
|
formatterDeleteCustom: "Elimina modello",
|
||||||
|
formatterDeleteCustomMessage: 'Eliminare "{name}"? La formattazione verrà rimossa da tutte le colonne che usano questo modello.',
|
||||||
formatterCustomName: "Nome modello",
|
formatterCustomName: "Nome modello",
|
||||||
formatterCustomNamePlaceholder: "Esempio: Etichetta utente",
|
formatterCustomNamePlaceholder: "Esempio: Etichetta utente",
|
||||||
formatterCustomTemplateInput: "Modello",
|
formatterCustomTemplateInput: "Modello",
|
||||||
|
|||||||
@@ -1628,6 +1628,8 @@ export default withEnglishFallback({
|
|||||||
formatterMaskSuffix: "表示する接尾辞",
|
formatterMaskSuffix: "表示する接尾辞",
|
||||||
formatterSavedCustom: "保存済みテンプレート",
|
formatterSavedCustom: "保存済みテンプレート",
|
||||||
formatterNewCustom: "新しいテンプレート",
|
formatterNewCustom: "新しいテンプレート",
|
||||||
|
formatterDeleteCustom: "テンプレートを削除",
|
||||||
|
formatterDeleteCustomMessage: "「{name}」を削除しますか?このテンプレートを使用しているすべての列から書式設定が解除されます。",
|
||||||
formatterCustomName: "テンプレート名",
|
formatterCustomName: "テンプレート名",
|
||||||
formatterCustomNamePlaceholder: "例: ユーザーラベル",
|
formatterCustomNamePlaceholder: "例: ユーザーラベル",
|
||||||
formatterCustomTemplateInput: "テンプレート",
|
formatterCustomTemplateInput: "テンプレート",
|
||||||
|
|||||||
@@ -1612,6 +1612,8 @@ export default withEnglishFallback({
|
|||||||
formatterMaskSuffix: "Sufixo visível",
|
formatterMaskSuffix: "Sufixo visível",
|
||||||
formatterSavedCustom: "Templates salvos",
|
formatterSavedCustom: "Templates salvos",
|
||||||
formatterNewCustom: "Novo template",
|
formatterNewCustom: "Novo template",
|
||||||
|
formatterDeleteCustom: "Excluir template",
|
||||||
|
formatterDeleteCustomMessage: 'Excluir "{name}"? A formatação será removida de todas as colunas que usam este template.',
|
||||||
formatterCustomName: "Nome do template",
|
formatterCustomName: "Nome do template",
|
||||||
formatterCustomNamePlaceholder: "Exemplo: Rótulo do usuário",
|
formatterCustomNamePlaceholder: "Exemplo: Rótulo do usuário",
|
||||||
formatterCustomTemplateInput: "Template",
|
formatterCustomTemplateInput: "Template",
|
||||||
|
|||||||
@@ -1653,6 +1653,8 @@ export default withEnglishFallback({
|
|||||||
formatterMaskSuffix: "后缀保留",
|
formatterMaskSuffix: "后缀保留",
|
||||||
formatterSavedCustom: "已保存模板",
|
formatterSavedCustom: "已保存模板",
|
||||||
formatterNewCustom: "新建模板",
|
formatterNewCustom: "新建模板",
|
||||||
|
formatterDeleteCustom: "删除模板",
|
||||||
|
formatterDeleteCustomMessage: "确定删除“{name}”吗?所有使用该模板的字段都会清除格式化设置。",
|
||||||
formatterCustomName: "模板名称",
|
formatterCustomName: "模板名称",
|
||||||
formatterCustomNamePlaceholder: "例如:用户标签",
|
formatterCustomNamePlaceholder: "例如:用户标签",
|
||||||
formatterCustomTemplateInput: "模板",
|
formatterCustomTemplateInput: "模板",
|
||||||
|
|||||||
@@ -1611,6 +1611,8 @@ export default withEnglishFallback({
|
|||||||
formatterMaskSuffix: "字尾保留",
|
formatterMaskSuffix: "字尾保留",
|
||||||
formatterSavedCustom: "已儲存範本",
|
formatterSavedCustom: "已儲存範本",
|
||||||
formatterNewCustom: "建立範本",
|
formatterNewCustom: "建立範本",
|
||||||
|
formatterDeleteCustom: "刪除範本",
|
||||||
|
formatterDeleteCustomMessage: "確定刪除「{name}」嗎?所有使用此範本的欄位都會清除格式設定。",
|
||||||
formatterCustomName: "範本名稱",
|
formatterCustomName: "範本名稱",
|
||||||
formatterCustomNamePlaceholder: "例如:使用者標籤",
|
formatterCustomNamePlaceholder: "例如:使用者標籤",
|
||||||
formatterCustomTemplateInput: "範本",
|
formatterCustomTemplateInput: "範本",
|
||||||
|
|||||||
@@ -805,6 +805,38 @@ describe("settingsStore editor settings persistence", () => {
|
|||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("initializes legacy settings before queueing formatter mutations", async () => {
|
||||||
|
const loadEditorSettings = vi.fn().mockResolvedValue({
|
||||||
|
updateDownloadSource: "atomgit",
|
||||||
|
customColumnFormatters: {},
|
||||||
|
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
|
||||||
|
});
|
||||||
|
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
|
||||||
|
|
||||||
|
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||||
|
const store = useSettingsStore();
|
||||||
|
const formatter = { id: "fmt_pre_init", name: "Pre-init", template: "legacy:${value}" };
|
||||||
|
|
||||||
|
await store.upsertCustomColumnFormatter(formatter);
|
||||||
|
|
||||||
|
expect(loadEditorSettings).toHaveBeenCalledOnce();
|
||||||
|
expect(saveEditorSettings).toHaveBeenCalledTimes(2);
|
||||||
|
expect(saveEditorSettings.mock.calls[0][0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
updateDownloadSource: "cnb",
|
||||||
|
customColumnFormatters: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(saveEditorSettings.mock.calls[1][0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
updateDownloadSource: "cnb",
|
||||||
|
customColumnFormatters: { fmt_pre_init: formatter },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.editorSettings.customColumnFormatters.fmt_pre_init).toEqual(formatter);
|
||||||
|
});
|
||||||
|
|
||||||
it("rolls back a failed atomic update and allows retry", async () => {
|
it("rolls back a failed atomic update and allows retry", async () => {
|
||||||
const loadEditorSettings = vi.fn().mockResolvedValue({
|
const loadEditorSettings = vi.fn().mockResolvedValue({
|
||||||
ignoredUpdateVersion: "",
|
ignoredUpdateVersion: "",
|
||||||
@@ -915,6 +947,65 @@ describe("settingsStore editor settings persistence", () => {
|
|||||||
expect(store.editorSettings).toMatchObject({ ignoredUpdateVersion: "", theme: "xcode-dark" });
|
expect(store.editorSettings).toMatchObject({ ignoredUpdateVersion: "", theme: "xcode-dark" });
|
||||||
expect(saveEditorSettings.mock.calls[1][0]).toEqual(expect.objectContaining({ ignoredUpdateVersion: "", theme: "xcode-dark" }));
|
expect(saveEditorSettings.mock.calls[1][0]).toEqual(expect.objectContaining({ ignoredUpdateVersion: "", theme: "xcode-dark" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes queued saves before a failed formatter delete", async () => {
|
||||||
|
let persistedSettings: Record<string, unknown> = {
|
||||||
|
customColumnFormatters: {
|
||||||
|
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
|
||||||
|
},
|
||||||
|
columnFormatters: {
|
||||||
|
first: { kind: "custom-ref", formatterId: "fmt_a" },
|
||||||
|
},
|
||||||
|
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
|
||||||
|
};
|
||||||
|
const loadEditorSettings = vi.fn(async () => JSON.parse(JSON.stringify(persistedSettings)));
|
||||||
|
const saveEditorSettings = vi.fn(async (settings: Record<string, unknown>) => {
|
||||||
|
persistedSettings = JSON.parse(JSON.stringify(settings));
|
||||||
|
});
|
||||||
|
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
|
||||||
|
|
||||||
|
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||||
|
const store = useSettingsStore();
|
||||||
|
await store.initEditorSettings();
|
||||||
|
saveEditorSettings.mockClear();
|
||||||
|
|
||||||
|
let resolveFirstSave!: () => void;
|
||||||
|
let saveCall = 0;
|
||||||
|
saveEditorSettings.mockImplementation(async (settings: Record<string, unknown>) => {
|
||||||
|
saveCall += 1;
|
||||||
|
if (saveCall === 1) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveFirstSave = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (saveCall === 3) throw new Error("delete save failed");
|
||||||
|
persistedSettings = JSON.parse(JSON.stringify(settings));
|
||||||
|
});
|
||||||
|
|
||||||
|
store.updateEditorSettings({ theme: "xcode-dark" });
|
||||||
|
await vi.waitFor(() => expect(saveEditorSettings).toHaveBeenCalledOnce());
|
||||||
|
store.updateEditorSettings({ resultRunDisplayMode: "list" });
|
||||||
|
const deletePromise = store.deleteCustomColumnFormatter("fmt_a");
|
||||||
|
const deleteRejected = expect(deletePromise).rejects.toThrow("delete save failed");
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.editorSettings.customColumnFormatters.fmt_a).toBeDefined();
|
||||||
|
|
||||||
|
resolveFirstSave();
|
||||||
|
await deleteRejected;
|
||||||
|
|
||||||
|
expect(saveEditorSettings).toHaveBeenCalledTimes(3);
|
||||||
|
expect(store.editorSettings.customColumnFormatters.fmt_a).toBeDefined();
|
||||||
|
expect(store.editorSettings.columnFormatters.first).toEqual({ kind: "custom-ref", formatterId: "fmt_a" });
|
||||||
|
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const restartedStore = useSettingsStore();
|
||||||
|
await restartedStore.initEditorSettings();
|
||||||
|
|
||||||
|
expect(restartedStore.editorSettings.customColumnFormatters.fmt_a).toEqual({ id: "fmt_a", name: "A", template: "a:${value}" });
|
||||||
|
expect(restartedStore.editorSettings.columnFormatters.first).toEqual({ kind: "custom-ref", formatterId: "fmt_a" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settingsStore sidebar connection sort persistence", () => {
|
describe("settingsStore sidebar connection sort persistence", () => {
|
||||||
|
|||||||
@@ -1304,23 +1304,34 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||||||
const isEditorSettingsLoaded = ref(false);
|
const isEditorSettingsLoaded = ref(false);
|
||||||
let initEditorSettingsPromise: Promise<void> | null = null;
|
let initEditorSettingsPromise: Promise<void> | null = null;
|
||||||
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
|
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
|
||||||
let editorSettingsSaveQueue: Promise<void> | null = null;
|
let editorSettingsOperationQueue: Promise<void> | null = null;
|
||||||
let editorSettingsAtomicUpdateQueue: Promise<void> = Promise.resolve();
|
|
||||||
let editorSettingsPatchRevision = 0;
|
let editorSettingsPatchRevision = 0;
|
||||||
const editorSettingsFieldRevisions = new Map<keyof EditorSettings, number>();
|
const editorSettingsFieldRevisions = new Map<keyof EditorSettings, number>();
|
||||||
|
const customColumnFormatterDeleteVersions = new Map<string, number>();
|
||||||
let pendingAiChatSelection: AiChatSelectionState | null = null;
|
let pendingAiChatSelection: AiChatSelectionState | null = null;
|
||||||
let aiChatSelectionSaveRunning = false;
|
let aiChatSelectionSaveRunning = false;
|
||||||
|
|
||||||
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
|
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
|
||||||
|
|
||||||
function enqueueEditorSettingsSave(): Promise<void> {
|
function enqueueEditorSettingsOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
const saveCurrentSettings = () => api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
|
const queuedOperation = editorSettingsOperationQueue ? editorSettingsOperationQueue.then(operation) : operation();
|
||||||
const save = editorSettingsSaveQueue ? editorSettingsSaveQueue.catch(() => {}).then(saveCurrentSettings) : saveCurrentSettings();
|
const trackedOperation = queuedOperation.then(
|
||||||
const trackedSave = save.finally(() => {
|
() => undefined,
|
||||||
if (editorSettingsSaveQueue === trackedSave) editorSettingsSaveQueue = null;
|
() => undefined,
|
||||||
|
);
|
||||||
|
editorSettingsOperationQueue = trackedOperation;
|
||||||
|
void trackedOperation.finally(() => {
|
||||||
|
if (editorSettingsOperationQueue === trackedOperation) editorSettingsOperationQueue = null;
|
||||||
});
|
});
|
||||||
editorSettingsSaveQueue = trackedSave;
|
return queuedOperation;
|
||||||
return trackedSave;
|
}
|
||||||
|
|
||||||
|
function persistCurrentEditorSettings(): Promise<void> {
|
||||||
|
return api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueEditorSettingsSave(): Promise<void> {
|
||||||
|
return enqueueEditorSettingsOperation(persistCurrentEditorSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEditorSettings() {
|
function saveEditorSettings() {
|
||||||
@@ -1823,32 +1834,33 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||||||
await enqueueEditorSettingsSave();
|
await enqueueEditorSettingsSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enqueueEditorSettingsAtomicMutation<T>(mutation: () => Promise<T>): Promise<T> {
|
||||||
|
await initEditorSettings();
|
||||||
|
return enqueueEditorSettingsOperation(mutation);
|
||||||
|
}
|
||||||
|
|
||||||
function updateEditorSettingsAndPersist(partial: Partial<EditorSettings>): Promise<void> {
|
function updateEditorSettingsAndPersist(partial: Partial<EditorSettings>): Promise<void> {
|
||||||
const update = editorSettingsAtomicUpdateQueue
|
return enqueueEditorSettingsAtomicMutation(async () => {
|
||||||
.catch(() => {})
|
await initEditorSettings();
|
||||||
.then(async () => {
|
const previous = editorSettingsSnapshot(editorSettings.value);
|
||||||
await initEditorSettings();
|
applyEditorSettingsPatch(partial);
|
||||||
const previous = editorSettingsSnapshot(editorSettings.value);
|
const revision = markEditorSettingsPatch(partial);
|
||||||
applyEditorSettingsPatch(partial);
|
try {
|
||||||
const revision = markEditorSettingsPatch(partial);
|
await persistCurrentEditorSettings();
|
||||||
try {
|
} catch (error) {
|
||||||
await enqueueEditorSettingsSave();
|
const restored = editorSettingsSnapshot(editorSettings.value) as unknown as Record<string, unknown>;
|
||||||
} catch (error) {
|
const previousSettings = previous as unknown as Record<string, unknown>;
|
||||||
const restored = editorSettingsSnapshot(editorSettings.value) as unknown as Record<string, unknown>;
|
let changed = false;
|
||||||
const previousSettings = previous as unknown as Record<string, unknown>;
|
for (const key of Object.keys(partial) as (keyof EditorSettings)[]) {
|
||||||
let changed = false;
|
if (partial[key] === undefined || editorSettingsFieldRevisions.get(key) !== revision) continue;
|
||||||
for (const key of Object.keys(partial) as (keyof EditorSettings)[]) {
|
restored[key] = previousSettings[key];
|
||||||
if (partial[key] === undefined || editorSettingsFieldRevisions.get(key) !== revision) continue;
|
editorSettingsFieldRevisions.set(key, ++editorSettingsPatchRevision);
|
||||||
restored[key] = previousSettings[key];
|
changed = true;
|
||||||
editorSettingsFieldRevisions.set(key, ++editorSettingsPatchRevision);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (changed) editorSettings.value = normalizeEditorSettings(restored);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
});
|
if (changed) editorSettings.value = normalizeEditorSettings(restored);
|
||||||
editorSettingsAtomicUpdateQueue = update.catch(() => {});
|
throw error;
|
||||||
return update;
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) {
|
function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) {
|
||||||
@@ -1862,29 +1874,101 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||||||
updateEditorSettings({ columnFormatters });
|
updateEditorSettings({ columnFormatters });
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertCustomColumnFormatter(formatter: CustomColumnFormatterConfig): CustomColumnFormatterConfig | undefined {
|
function customColumnFormatterDeleteVersion(id: string): number {
|
||||||
const normalized = normalizeCustomColumnFormatter(formatter);
|
return customColumnFormatterDeleteVersions.get(id) ?? 0;
|
||||||
if (!normalized) return undefined;
|
|
||||||
updateEditorSettings({
|
|
||||||
customColumnFormatters: {
|
|
||||||
...editorSettings.value.customColumnFormatters,
|
|
||||||
[normalized.id]: normalized,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCustomColumnFormatter(id: string) {
|
async function upsertCustomColumnFormatter(formatter: CustomColumnFormatterConfig, expectedDeleteVersion?: number): Promise<CustomColumnFormatterConfig | undefined> {
|
||||||
const customColumnFormatters = {
|
const normalized = normalizeCustomColumnFormatter(formatter);
|
||||||
...editorSettings.value.customColumnFormatters,
|
if (!normalized) return undefined;
|
||||||
};
|
return enqueueEditorSettingsAtomicMutation(async () => {
|
||||||
delete customColumnFormatters[id];
|
await initEditorSettings();
|
||||||
const columnFormatters = Object.fromEntries(
|
if (expectedDeleteVersion !== undefined && customColumnFormatterDeleteVersion(normalized.id) !== expectedDeleteVersion) return undefined;
|
||||||
Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => {
|
const previous = editorSettings.value.customColumnFormatters[normalized.id];
|
||||||
return formatter.kind !== "custom-ref" || formatter.formatterId !== id;
|
const partial = {
|
||||||
}),
|
customColumnFormatters: {
|
||||||
);
|
...editorSettings.value.customColumnFormatters,
|
||||||
updateEditorSettings({ customColumnFormatters, columnFormatters });
|
[normalized.id]: normalized,
|
||||||
|
},
|
||||||
|
} satisfies Partial<EditorSettings>;
|
||||||
|
applyEditorSettingsPatch(partial);
|
||||||
|
markEditorSettingsPatch(partial);
|
||||||
|
try {
|
||||||
|
await persistCurrentEditorSettings();
|
||||||
|
} catch (error) {
|
||||||
|
const customColumnFormatters = {
|
||||||
|
...editorSettings.value.customColumnFormatters,
|
||||||
|
};
|
||||||
|
const current = customColumnFormatters[normalized.id];
|
||||||
|
if (current?.name === normalized.name && current.template === normalized.template) {
|
||||||
|
if (previous) {
|
||||||
|
customColumnFormatters[normalized.id] = previous;
|
||||||
|
} else {
|
||||||
|
delete customColumnFormatters[normalized.id];
|
||||||
|
}
|
||||||
|
const rollback = { customColumnFormatters } satisfies Partial<EditorSettings>;
|
||||||
|
applyEditorSettingsPatch(rollback);
|
||||||
|
markEditorSettingsPatch(rollback);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCustomColumnFormatter(id: string): Promise<void> {
|
||||||
|
return enqueueEditorSettingsAtomicMutation(async () => {
|
||||||
|
await initEditorSettings();
|
||||||
|
const previousDeleteVersion = customColumnFormatterDeleteVersion(id);
|
||||||
|
const deleteVersion = previousDeleteVersion + 1;
|
||||||
|
customColumnFormatterDeleteVersions.set(id, deleteVersion);
|
||||||
|
const previousFormatter = editorSettings.value.customColumnFormatters[id];
|
||||||
|
const customColumnFormatters = {
|
||||||
|
...editorSettings.value.customColumnFormatters,
|
||||||
|
};
|
||||||
|
delete customColumnFormatters[id];
|
||||||
|
const removedColumnFormatters = Object.fromEntries(
|
||||||
|
Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => {
|
||||||
|
return formatter.kind === "custom-ref" && formatter.formatterId === id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const columnFormatters = Object.fromEntries(
|
||||||
|
Object.entries(editorSettings.value.columnFormatters).filter(([, formatter]) => {
|
||||||
|
return formatter.kind !== "custom-ref" || formatter.formatterId !== id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const partial = { customColumnFormatters, columnFormatters } satisfies Partial<EditorSettings>;
|
||||||
|
applyEditorSettingsPatch(partial);
|
||||||
|
markEditorSettingsPatch(partial);
|
||||||
|
try {
|
||||||
|
await persistCurrentEditorSettings();
|
||||||
|
} catch (error) {
|
||||||
|
if (customColumnFormatterDeleteVersion(id) === deleteVersion) {
|
||||||
|
if (previousDeleteVersion === 0) {
|
||||||
|
customColumnFormatterDeleteVersions.delete(id);
|
||||||
|
} else {
|
||||||
|
customColumnFormatterDeleteVersions.set(id, previousDeleteVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const restoredCustomColumnFormatters = {
|
||||||
|
...editorSettings.value.customColumnFormatters,
|
||||||
|
};
|
||||||
|
if (previousFormatter && !restoredCustomColumnFormatters[id]) restoredCustomColumnFormatters[id] = previousFormatter;
|
||||||
|
const restoredColumnFormatters = {
|
||||||
|
...editorSettings.value.columnFormatters,
|
||||||
|
};
|
||||||
|
for (const [key, formatter] of Object.entries(removedColumnFormatters)) {
|
||||||
|
if (!restoredColumnFormatters[key]) restoredColumnFormatters[key] = formatter;
|
||||||
|
}
|
||||||
|
const rollback = {
|
||||||
|
customColumnFormatters: restoredCustomColumnFormatters,
|
||||||
|
columnFormatters: restoredColumnFormatters,
|
||||||
|
} satisfies Partial<EditorSettings>;
|
||||||
|
applyEditorSettingsPatch(rollback);
|
||||||
|
markEditorSettingsPatch(rollback);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1921,6 +2005,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||||||
initMcpGlobalPolicy,
|
initMcpGlobalPolicy,
|
||||||
updateMcpGlobalPolicy,
|
updateMcpGlobalPolicy,
|
||||||
updateColumnFormatter,
|
updateColumnFormatter,
|
||||||
|
customColumnFormatterDeleteVersion,
|
||||||
upsertCustomColumnFormatter,
|
upsertCustomColumnFormatter,
|
||||||
deleteCustomColumnFormatter,
|
deleteCustomColumnFormatter,
|
||||||
};
|
};
|
||||||
|
|||||||
+172
@@ -29,6 +29,16 @@ beforeEach(() => {
|
|||||||
saveEditorSettingsMock.mockClear();
|
saveEditorSettingsMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createDeferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
async function withMockLocalStorage(initial: Record<string, string>, run: () => void | Promise<void>) {
|
async function withMockLocalStorage(initial: Record<string, string>, run: () => void | Promise<void>) {
|
||||||
const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||||
const values = new Map(Object.entries(initial));
|
const values = new Map(Object.entries(initial));
|
||||||
@@ -627,6 +637,168 @@ test("keeps only valid saved column formatter configs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("deleting a saved custom formatter clears every reference to it", async () => {
|
||||||
|
await withMockLocalStorage({}, async () => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const store = useSettingsStore();
|
||||||
|
await store.initEditorSettings();
|
||||||
|
store.updateEditorSettings({
|
||||||
|
customColumnFormatters: {
|
||||||
|
fmt_remove: { id: "fmt_remove", name: "Remove me", template: "remove:${value}" },
|
||||||
|
fmt_keep: { id: "fmt_keep", name: "Keep me", template: "keep:${value}" },
|
||||||
|
},
|
||||||
|
columnFormatters: {
|
||||||
|
first: { kind: "custom-ref", formatterId: "fmt_remove" },
|
||||||
|
second: { kind: "custom-ref", formatterId: "fmt_remove" },
|
||||||
|
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
|
||||||
|
mask: { kind: "mask", prefix: 1, suffix: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.deleteCustomColumnFormatter("fmt_remove");
|
||||||
|
|
||||||
|
assert.deepEqual(store.editorSettings.customColumnFormatters, {
|
||||||
|
fmt_keep: { id: "fmt_keep", name: "Keep me", template: "keep:${value}" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(store.editorSettings.columnFormatters, {
|
||||||
|
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
|
||||||
|
mask: { kind: "mask", prefix: 1, suffix: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializes concurrent custom formatter deletes", async () => {
|
||||||
|
await withMockLocalStorage({}, async () => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const store = useSettingsStore();
|
||||||
|
await store.initEditorSettings();
|
||||||
|
store.updateEditorSettings({
|
||||||
|
customColumnFormatters: {
|
||||||
|
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
|
||||||
|
fmt_b: { id: "fmt_b", name: "B", template: "b:${value}" },
|
||||||
|
fmt_keep: { id: "fmt_keep", name: "Keep", template: "keep:${value}" },
|
||||||
|
},
|
||||||
|
columnFormatters: {
|
||||||
|
first: { kind: "custom-ref", formatterId: "fmt_a" },
|
||||||
|
second: { kind: "custom-ref", formatterId: "fmt_b" },
|
||||||
|
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.persistEditorSettings();
|
||||||
|
|
||||||
|
await Promise.all([store.deleteCustomColumnFormatter("fmt_a"), store.deleteCustomColumnFormatter("fmt_b")]);
|
||||||
|
|
||||||
|
assert.deepEqual(store.editorSettings.customColumnFormatters, {
|
||||||
|
fmt_keep: { id: "fmt_keep", name: "Keep", template: "keep:${value}" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(store.editorSettings.columnFormatters, {
|
||||||
|
keep: { kind: "custom-ref", formatterId: "fmt_keep" },
|
||||||
|
});
|
||||||
|
const saved = saveEditorSettingsMock.mock.calls.at(-1)?.[0] as { customColumnFormatters?: Record<string, unknown> } | undefined;
|
||||||
|
assert.deepEqual(saved?.customColumnFormatters, {
|
||||||
|
fmt_keep: { id: "fmt_keep", name: "Keep", template: "keep:${value}" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queues formatter upserts behind a deferred delete save", async () => {
|
||||||
|
await withMockLocalStorage({}, async () => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const store = useSettingsStore();
|
||||||
|
await store.initEditorSettings();
|
||||||
|
store.updateEditorSettings({
|
||||||
|
customColumnFormatters: {
|
||||||
|
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
|
||||||
|
fmt_b: { id: "fmt_b", name: "B", template: "b:${value}" },
|
||||||
|
},
|
||||||
|
columnFormatters: {
|
||||||
|
first: { kind: "custom-ref", formatterId: "fmt_a" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.persistEditorSettings();
|
||||||
|
const deleteSave = createDeferred();
|
||||||
|
saveEditorSettingsMock.mockImplementationOnce(() => deleteSave.promise);
|
||||||
|
|
||||||
|
const deletePromise = store.deleteCustomColumnFormatter("fmt_a");
|
||||||
|
await vi.waitFor(() => assert.equal(store.editorSettings.customColumnFormatters.fmt_a, undefined));
|
||||||
|
const upsertPromise = store.upsertCustomColumnFormatter({ id: "fmt_b", name: "B updated", template: "updated:${value}" });
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(store.editorSettings.customColumnFormatters.fmt_b.name, "B");
|
||||||
|
|
||||||
|
deleteSave.resolve();
|
||||||
|
await deletePromise;
|
||||||
|
await upsertPromise;
|
||||||
|
|
||||||
|
assert.deepEqual(store.editorSettings.customColumnFormatters, {
|
||||||
|
fmt_b: { id: "fmt_b", name: "B updated", template: "updated:${value}" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(store.editorSettings.columnFormatters, {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rolls back a failed delete before running a queued formatter upsert", async () => {
|
||||||
|
await withMockLocalStorage({}, async () => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const store = useSettingsStore();
|
||||||
|
await store.initEditorSettings();
|
||||||
|
store.updateEditorSettings({
|
||||||
|
customColumnFormatters: {
|
||||||
|
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
|
||||||
|
},
|
||||||
|
columnFormatters: {
|
||||||
|
first: { kind: "custom-ref", formatterId: "fmt_a" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.persistEditorSettings();
|
||||||
|
const deleteSave = createDeferred();
|
||||||
|
saveEditorSettingsMock.mockImplementationOnce(() => deleteSave.promise);
|
||||||
|
|
||||||
|
const deletePromise = store.deleteCustomColumnFormatter("fmt_a");
|
||||||
|
const deleteRejected = assert.rejects(deletePromise, /disk full/);
|
||||||
|
await vi.waitFor(() => assert.equal(store.editorSettings.customColumnFormatters.fmt_a, undefined));
|
||||||
|
const upsertPromise = store.upsertCustomColumnFormatter({ id: "fmt_b", name: "B", template: "b:${value}" });
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(store.editorSettings.customColumnFormatters.fmt_b, undefined);
|
||||||
|
|
||||||
|
deleteSave.reject(new Error("disk full"));
|
||||||
|
await deleteRejected;
|
||||||
|
await upsertPromise;
|
||||||
|
|
||||||
|
assert.deepEqual(store.editorSettings.customColumnFormatters, {
|
||||||
|
fmt_a: { id: "fmt_a", name: "A", template: "a:${value}" },
|
||||||
|
fmt_b: { id: "fmt_b", name: "B", template: "b:${value}" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(store.editorSettings.columnFormatters, {
|
||||||
|
first: { kind: "custom-ref", formatterId: "fmt_a" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects stale formatter drafts but allows deliberate same-id recreation", async () => {
|
||||||
|
await withMockLocalStorage({}, async () => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const store = useSettingsStore();
|
||||||
|
await store.initEditorSettings();
|
||||||
|
store.updateEditorSettings({
|
||||||
|
customColumnFormatters: {
|
||||||
|
fmt_rebuild: { id: "fmt_rebuild", name: "Original", template: "original:${value}" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.persistEditorSettings();
|
||||||
|
const capturedDeleteVersion = store.customColumnFormatterDeleteVersion("fmt_rebuild");
|
||||||
|
const staleFormatter = store.editorSettings.customColumnFormatters.fmt_rebuild;
|
||||||
|
|
||||||
|
await store.deleteCustomColumnFormatter("fmt_rebuild");
|
||||||
|
|
||||||
|
assert.equal(await store.upsertCustomColumnFormatter(staleFormatter, capturedDeleteVersion), undefined);
|
||||||
|
assert.equal(store.editorSettings.customColumnFormatters.fmt_rebuild, undefined);
|
||||||
|
const rebuilt = { id: "fmt_rebuild", name: "Rebuilt", template: "rebuilt:${value}" };
|
||||||
|
assert.deepEqual(await store.upsertCustomColumnFormatter(rebuilt), rebuilt);
|
||||||
|
assert.deepEqual(store.editorSettings.customColumnFormatters.fmt_rebuild, rebuilt);
|
||||||
|
assert.notEqual(store.customColumnFormatterDeleteVersion("fmt_rebuild"), capturedDeleteVersion);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("AI provider presets include common hosted and local providers", () => {
|
test("AI provider presets include common hosted and local providers", () => {
|
||||||
assert.equal(AI_PROVIDER_PRESETS.gemini.endpoint, "https://generativelanguage.googleapis.com");
|
assert.equal(AI_PROVIDER_PRESETS.gemini.endpoint, "https://generativelanguage.googleapis.com");
|
||||||
assert.equal(AI_PROVIDER_PRESETS.gemini.model, "gemini-1.5-pro");
|
assert.equal(AI_PROVIDER_PRESETS.gemini.model, "gemini-1.5-pro");
|
||||||
|
|||||||
Reference in new issue
Block a user