diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 27d931ae72..bf236ef2ad 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -4253,12 +4253,14 @@ interface BatchColumnSelectionActionItem { detail: string; boost: number; batchColumnSelectionAction: true; + /** Toggles every candidate checkbox instead of inserting the selection. */ + batchColumnSelectionToggleAll?: true; sessionKey: string; } type QueryCompletionOption = Completion & { dbxBatchColumnSelection?: { sessionKey: string; candidateKey: string }; - dbxBatchColumnSelectionAction?: { sessionKey: string }; + dbxBatchColumnSelectionAction?: { sessionKey: string; toggleAll?: true }; }; let batchColumnSelectionSession: BatchColumnSelectionSession | null = null; @@ -4279,7 +4281,7 @@ interface BatchColumnSelectionDragState { } const batchColumnSelectionCheckboxMarkers = new WeakMap(); -const batchColumnSelectionActionMarkers = new WeakMap(); +const batchColumnSelectionActionMarkers = new WeakMap(); const batchColumnSelectionTooltipParents = new WeakMap(); let batchColumnSelectionDragState: BatchColumnSelectionDragState | null = null; let batchColumnSelectionRefreshCleanup: (() => void) | null = null; @@ -4314,19 +4316,37 @@ function setBatchColumnSelectionValue(sessionKey: string, candidateKey: string, if (checkbox) checkbox.checked = selected; } +function batchColumnSelectionAllSelected(session: BatchColumnSelectionSession): boolean { + return session.candidates.length > 0 && session.selectedKeys.size === session.candidates.length; +} + function updateBatchColumnSelectionActionLabel(view: EditorViewType, sessionKey: string) { const session = batchColumnSelectionSession; if (!session || session.key !== sessionKey) return; - const label = t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }); + const insertLabel = t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }); + const toggleAllLabel = batchColumnSelectionAllSelected(session) ? t("editor.completion.deselectAllColumns") : t("editor.completion.selectAllColumns"); const tooltipParent = batchColumnSelectionTooltipParents.get(view) ?? view.dom; tooltipParent.querySelectorAll(".cm-batch-column-selection-action-marker").forEach((marker) => { - if (batchColumnSelectionActionMarkers.get(marker) !== sessionKey) return; + const markerState = batchColumnSelectionActionMarkers.get(marker); + if (!markerState || markerState.sessionKey !== sessionKey) return; const element = marker.closest("li")?.querySelector(".cm-completionLabel"); if (!element) return; + const label = markerState.toggleAll ? toggleAllLabel : insertLabel; if (element.textContent !== label) element.textContent = label; }); } +/** Check or clear every field checkbox of the active batch selection. */ +function toggleAllBatchColumnSelection(view: EditorViewType, sessionKey: string) { + const session = batchColumnSelectionSession; + if (!session || session.key !== sessionKey) return; + const selectAll = !batchColumnSelectionAllSelected(session); + session.selectedKeys = selectAll ? new Set(session.candidates.map((candidate) => candidate.key)) : new Set(); + updateBatchColumnSelectionActionLabel(view, sessionKey); + // Reopen the list so every rendered checkbox and both action rows reflect the new state. + scheduleBatchColumnSelectionRefresh(view, sessionKey, session.candidates[0]?.key ?? ""); +} + function scheduleBatchColumnSelectionRefresh(view: EditorViewType, sessionKey: string, focusCandidateKey: string, scrollState?: { element: HTMLElement | null; top: number; left: number }) { cancelBatchColumnSelectionRefresh(); const preservedScroll = @@ -4684,7 +4704,7 @@ function renderBatchColumnSelectionActionMarker(completion: Completion): Node | const marker = document.createElement("span"); marker.className = "cm-batch-column-selection-action-marker"; marker.hidden = true; - batchColumnSelectionActionMarkers.set(marker, action.sessionKey); + batchColumnSelectionActionMarkers.set(marker, { sessionKey: action.sessionKey, toggleAll: action.toggleAll === true }); return marker; } @@ -4806,19 +4826,32 @@ function buildCompletionResult(items: Array 0 ? [...replacement.items, ...actions] : replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix); } // CodeMirror's built-in matcher only matches single-character queries against @@ -4885,11 +4918,17 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio return { ...labelPresentation, ...(sortText ? { sortText } : {}), - dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey }, + dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey, ...(item.batchColumnSelectionToggleAll ? { toggleAll: true as const } : {}) }, type: item.type, detail: item.detail, boost: item.boost, apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) { + if (item.batchColumnSelectionToggleAll) { + // Toggling the selection never edits the document; the checkout rows + // (and the insert action) are refreshed in place instead. + toggleAllBatchColumnSelection(view, item.sessionKey); + return; + } applyBatchColumnSelection(view, item, from, to); }, }; diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts index 9271d9f347..0063c62de3 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts @@ -163,7 +163,7 @@ describe("QueryEditor batch column selection", () => { }); it("marks the insertion action so the completion menu can keep it sticky", () => { - expect(source).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey }"); + expect(source).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey, ...(item.batchColumnSelectionToggleAll ? { toggleAll: true as const } : {}) }"); expect(source).toContain('optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : "")'); }); @@ -175,8 +175,8 @@ describe("QueryEditor batch column selection", () => { expect(source.slice(dragUpdateStart, dragUpdateEnd)).toContain("updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);"); expect(source).toContain("const batchColumnSelectionTooltipParents = new WeakMap();"); expect(source).toContain("batchColumnSelectionTooltipParents.set(view.value, tooltipParent);"); - expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap();"); - expect(source).toContain("batchColumnSelectionActionMarkers.get(marker) !== sessionKey"); + expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap();"); + expect(source).toContain("!markerState || markerState.sessionKey !== sessionKey"); expect(source).toContain("renderBatchColumnSelectionActionMarker"); }); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index b301311074..883fca6a3e 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -1378,6 +1378,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count} sütun", insertSelectedColumns: "Seçilmiş sütunları daxil et ({count})", insertSelectedColumnsDetail: "Seçim xanaları ilə sütunları seçin, sonra onları birlikdə daxil edin", + selectAllColumns: "Bütün sütunları seç", + selectAllColumnsDetail: "Bütün sahələri bir dəfə seç", + deselectAllColumns: "Seçimi təmizlə", selectColumnsBeforeInsert: "Əvvəlcə ən azı bir sütun seçin", tableAlias: "Cədvəlin ləqəbi", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8dcdf47850..1fab9e010b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1529,6 +1529,9 @@ export default { starExpansionColumns: "{count} columns", insertSelectedColumns: "Insert selected columns ({count})", insertSelectedColumnsDetail: "Choose columns with the checkboxes, then insert them together", + selectAllColumns: "Select all columns", + selectAllColumnsDetail: "Check every field at once", + deselectAllColumns: "Deselect all", selectColumnsBeforeInsert: "Select at least one column first", tableAlias: "Table alias", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6b3a6ec938..4d1925a374 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1496,6 +1496,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count} columnas", insertSelectedColumns: "Insertar columnas seleccionadas ({count})", insertSelectedColumnsDetail: "Selecciona columnas y, después, insértalas juntas", + selectAllColumns: "Seleccionar todas las columnas", + selectAllColumnsDetail: "Marcar todos los campos a la vez", + deselectAllColumns: "Desmarcar todo", selectColumnsBeforeInsert: "Selecciona al menos una columna primero", tableAlias: "Alias de tabla", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 60e960e4c6..b90df8a802 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1494,6 +1494,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count} colonne", insertSelectedColumns: "Inserisci colonne selezionate ({count})", insertSelectedColumnsDetail: "Seleziona le colonne e poi inseriscile insieme", + selectAllColumns: "Seleziona tutte le colonne", + selectAllColumnsDetail: "Seleziona tutti i campi in una volta", + deselectAllColumns: "Deseleziona tutto", selectColumnsBeforeInsert: "Seleziona prima almeno una colonna", tableAlias: "Alias della tabella", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d86b5a5b70..7f9d55db7a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1514,6 +1514,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count}列", insertSelectedColumns: "選択した列を挿入({count})", insertSelectedColumnsDetail: "チェックした列をまとめて挿入します", + selectAllColumns: "すべての列を選択", + selectAllColumnsDetail: "すべてのフィールドをまとめてチェック", + deselectAllColumns: "選択をすべて解除", selectColumnsBeforeInsert: "少なくとも 1 列選択してください", tableAlias: "テーブルエイリアス", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index c5a4a27212..ae9ef12546 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1414,6 +1414,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count}개 컬럼", insertSelectedColumns: "선택한 열 삽입 ({count})", insertSelectedColumnsDetail: "열을 선택한 후 한 번에 삽입합니다", + selectAllColumns: "모든 열 선택", + selectAllColumnsDetail: "모든 필드를 한 번에 선택", + deselectAllColumns: "전체 선택 해제", selectColumnsBeforeInsert: "먼저 하나 이상의 열을 선택하세요", tableAlias: "테이블 별칭", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 25bda7890f..8213bac1c4 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1495,6 +1495,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count} colunas", insertSelectedColumns: "Inserir colunas selecionadas ({count})", insertSelectedColumnsDetail: "Selecione as colunas e insira-as de uma vez", + selectAllColumns: "Selecionar todas as colunas", + selectAllColumnsDetail: "Marcar todos os campos de uma vez", + deselectAllColumns: "Desmarcar tudo", selectColumnsBeforeInsert: "Selecione pelo menos uma coluna primeiro", tableAlias: "Alias da tabela", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 4551b005e2..b94dc22b5b 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -1380,6 +1380,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count} sütun", insertSelectedColumns: "Seçili sütunları ekle ({count})", insertSelectedColumnsDetail: "Onay kutularıyla sütunları seçip birlikte ekleyin", + selectAllColumns: "Tüm sütunları seç", + selectAllColumnsDetail: "Tüm alanları tek seferde işaretle", + deselectAllColumns: "Tüm seçimi kaldır", selectColumnsBeforeInsert: "Önce en az bir sütun seçin", tableAlias: "Tablo takma adı", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index c108258b69..760da747bd 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1444,6 +1444,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count} 列", insertSelectedColumns: "插入已选字段({count})", insertSelectedColumnsDetail: "勾选字段后一次性插入", + selectAllColumns: "全选字段", + selectAllColumnsDetail: "一键勾选全部字段", + deselectAllColumns: "取消全选", selectColumnsBeforeInsert: "请至少选择一个字段", tableAlias: "表别名", functionDescriptions: { diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3d5c58d758..1063efdc5a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1497,6 +1497,9 @@ export default withEnglishFallback({ starExpansionColumns: "{count} 欄", insertSelectedColumns: "插入已選欄位({count})", insertSelectedColumnsDetail: "勾選欄位後一次插入", + selectAllColumns: "全選欄位", + selectAllColumnsDetail: "一鍵勾選全部欄位", + deselectAllColumns: "取消全選", selectColumnsBeforeInsert: "請至少選擇一個欄位", tableAlias: "資料表別名", functionDescriptions: { diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorBatchColumnSelectionSelectAll.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorBatchColumnSelectionSelectAll.spec.ts new file mode 100644 index 0000000000..bd5b4f581c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorBatchColumnSelectionSelectAll.spec.ts @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it, vi } from "vitest"; + +// Regression coverage for #9122: the batch-selection completion list offered a +// per-field checkbox plus an "insert selected fields" row, but no way to check +// every field at once. See toggleAllBatchColumnSelection in QueryEditor.vue. +// happy-dom's global URL polyfill rejects the file: scheme, so resolve the +// path with Node's own path/url helpers instead of `new URL(..., import.meta.url)`. +const queryEditorSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../components/editor/QueryEditor.vue"), "utf8"); + +function extractFunction(name: string): string { + const start = queryEditorSource.indexOf(`function ${name}(`); + if (start < 0) throw new Error(`Missing QueryEditor function: ${name}`); + // The signature can carry an inline object return type (its own `{ }`) + // before the body starts, so take the *last* brace on the signature line + // rather than the first brace after the function name. + const signatureEnd = queryEditorSource.indexOf("\n", start); + const bodyStart = queryEditorSource.lastIndexOf("{", signatureEnd); + let depth = 0; + for (let index = bodyStart; index < queryEditorSource.length; index++) { + const character = queryEditorSource[index]; + if (character === "{") depth++; + if (character === "}" && --depth === 0) return queryEditorSource.slice(start, index + 1); + } + throw new Error("Unterminated QueryEditor function: " + name); +} + +interface Session { + key: string; + candidates: Array<{ key: string; apply: string }>; + selectedKeys: Set; +} + +interface SelectAllHarness { + toggleAllBatchColumnSelection: (view: unknown, sessionKey: string) => void; + setSession: (session: Session | null) => void; + getSelectedKeys: () => Set; + refreshCalls: Array<{ sessionKey: string; focusCandidateKey: string }>; +} + +function createHarness(): SelectAllHarness { + const refreshCalls: Array<{ sessionKey: string; focusCandidateKey: string }> = []; + const source = [ + "let batchColumnSelectionSession = null;", + extractFunction("batchColumnSelectionAllSelected"), + // The label refresh and list reopen are UI side effects; the selection + // decision itself is what this spec covers. + "function updateBatchColumnSelectionActionLabel() {}", + "function scheduleBatchColumnSelectionRefresh(view, sessionKey, focusCandidateKey) { __onRefresh(sessionKey, focusCandidateKey); }", + extractFunction("toggleAllBatchColumnSelection"), + ].join("\n"); + const javascript = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const factory = new Function( + "view", + "__onRefresh", + `${javascript} +return { + toggleAllBatchColumnSelection, + setSession: (session) => { batchColumnSelectionSession = session; }, + getSelectedKeys: () => batchColumnSelectionSession.selectedKeys, +};`, + ); + const harness = factory({}, (sessionKey: string, focusCandidateKey: string) => { + refreshCalls.push({ sessionKey, focusCandidateKey }); + }) as Omit; + return Object.assign(harness, { refreshCalls }); +} + +function createSession(selected: string[] = []): Session { + return { + key: "select\u0000wide_table", + candidates: [ + { key: "c1", apply: "col_01" }, + { key: "c2", apply: "col_02" }, + { key: "c3", apply: "col_03" }, + ], + selectedKeys: new Set(selected), + }; +} + +describe("toggleAllBatchColumnSelection (#9122)", () => { + it("selects every candidate field when the selection is empty", () => { + const harness = createHarness(); + const session = createSession(); + harness.setSession(session); + + harness.toggleAllBatchColumnSelection({}, session.key); + + expect(harness.getSelectedKeys()).toEqual(new Set(["c1", "c2", "c3"])); + // The list is reopened so every rendered checkbox shows the new state. + expect(harness.refreshCalls).toEqual([{ sessionKey: session.key, focusCandidateKey: "c1" }]); + }); + + it("selects the remaining fields when only some are checked", () => { + const harness = createHarness(); + const session = createSession(["c2"]); + harness.setSession(session); + + harness.toggleAllBatchColumnSelection({}, session.key); + + expect(harness.getSelectedKeys()).toEqual(new Set(["c1", "c2", "c3"])); + }); + + it("clears the selection once every field is checked", () => { + const harness = createHarness(); + const session = createSession(["c1", "c2", "c3"]); + harness.setSession(session); + + harness.toggleAllBatchColumnSelection({}, session.key); + + expect(harness.getSelectedKeys()).toEqual(new Set()); + }); + + it("ignores a stale session key", () => { + const harness = createHarness(); + const session = createSession(["c1"]); + harness.setSession(session); + + harness.toggleAllBatchColumnSelection({}, "another-session"); + + expect(harness.getSelectedKeys()).toEqual(new Set(["c1"])); + expect(harness.refreshCalls).toEqual([]); + }); + + it("does not reopen the list when no session is active", () => { + const harness = createHarness(); + harness.setSession(null); + + expect(() => harness.toggleAllBatchColumnSelection({}, "select\u0000wide_table")).not.toThrow(); + expect(harness.refreshCalls).toEqual([]); + vi.restoreAllMocks(); + }); +}); diff --git a/docs/screenshots/issue-9122-select-all-columns.png b/docs/screenshots/issue-9122-select-all-columns.png new file mode 100644 index 0000000000..7977bcbb5c Binary files /dev/null and b/docs/screenshots/issue-9122-select-all-columns.png differ