feat(editor): select all and deselect all in completion column picker

This commit is contained in:
zipg authored and GitHub committed 2026-09-19 10:55:21 +08:00
1 parent 257e023120
commit ffb915061f
14 files changed
+230 -22

No files matched your search

@@ -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<HTMLElement, BatchColumnSelectionCheckboxMarker>();
const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, string>();
const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();
const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();
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<HTMLElement>(".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<HTMLElement>(".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<QueryCompletionItem | BatchColumnSel
function buildSqlCompletionResult(items: SqlCompletionItem[], completionContext: SqlCompletionContext, fullDoc: string, position: number) {
const replacement = prepareSqlCompletionReplacement(fullDoc, position, completionContext, items);
const session = prepareBatchColumnSelectionSession(replacement.items, fullDoc, replacement.from, position);
const action: BatchColumnSelectionActionItem | undefined = session
? {
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
filterText: completionContext.prefix,
type: "text",
detail: t("editor.completion.insertSelectedColumnsDetail"),
// Keep ordinary single-field completion as the default Enter target.
boost: -1000,
batchColumnSelectionAction: true,
sessionKey: session.key,
}
: undefined;
return buildCompletionResult(action ? [...replacement.items, action] : replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
const actions: BatchColumnSelectionActionItem[] = session
? [
{
label: batchColumnSelectionAllSelected(session) ? t("editor.completion.deselectAllColumns") : t("editor.completion.selectAllColumns"),
filterText: completionContext.prefix,
type: "text",
detail: t("editor.completion.selectAllColumnsDetail"),
// Keep ordinary single-field completion as the default Enter target.
boost: -1000,
batchColumnSelectionAction: true,
batchColumnSelectionToggleAll: true,
sessionKey: session.key,
},
{
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
filterText: completionContext.prefix,
type: "text",
detail: t("editor.completion.insertSelectedColumnsDetail"),
// Keep ordinary single-field completion as the default Enter target.
boost: -1000,
batchColumnSelectionAction: true,
sessionKey: session.key,
},
]
: [];
return buildCompletionResult(actions.length > 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);
},
};
@@ -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<EditorViewType, HTMLElement>();");
expect(source).toContain("batchColumnSelectionTooltipParents.set(view.value, tooltipParent);");
expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, string>();");
expect(source).toContain("batchColumnSelectionActionMarkers.get(marker) !== sessionKey");
expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();");
expect(source).toContain("!markerState || markerState.sessionKey !== sessionKey");
expect(source).toContain("renderBatchColumnSelectionActionMarker");
});
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -1514,6 +1514,9 @@ export default withEnglishFallback({
starExpansionColumns: "{count}列",
insertSelectedColumns: "選択した列を挿入({count})",
insertSelectedColumnsDetail: "チェックした列をまとめて挿入します",
selectAllColumns: "すべての列を選択",
selectAllColumnsDetail: "すべてのフィールドをまとめてチェック",
deselectAllColumns: "選択をすべて解除",
selectColumnsBeforeInsert: "少なくとも 1 列選択してください",
tableAlias: "テーブルエイリアス",
functionDescriptions: {
+3
View File
@@ -1414,6 +1414,9 @@ export default withEnglishFallback({
starExpansionColumns: "{count}개 컬럼",
insertSelectedColumns: "선택한 열 삽입 ({count})",
insertSelectedColumnsDetail: "열을 선택한 후 한 번에 삽입합니다",
selectAllColumns: "모든 열 선택",
selectAllColumnsDetail: "모든 필드를 한 번에 선택",
deselectAllColumns: "전체 선택 해제",
selectColumnsBeforeInsert: "먼저 하나 이상의 열을 선택하세요",
tableAlias: "테이블 별칭",
functionDescriptions: {
+3
View File
@@ -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: {
+3
View File
@@ -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: {
+3
View File
@@ -1444,6 +1444,9 @@ export default withEnglishFallback({
starExpansionColumns: "{count} 列",
insertSelectedColumns: "插入已选字段({count})",
insertSelectedColumnsDetail: "勾选字段后一次性插入",
selectAllColumns: "全选字段",
selectAllColumnsDetail: "一键勾选全部字段",
deselectAllColumns: "取消全选",
selectColumnsBeforeInsert: "请至少选择一个字段",
tableAlias: "表别名",
functionDescriptions: {
+3
View File
@@ -1497,6 +1497,9 @@ export default withEnglishFallback({
starExpansionColumns: "{count} 欄",
insertSelectedColumns: "插入已選欄位({count})",
insertSelectedColumnsDetail: "勾選欄位後一次插入",
selectAllColumns: "全選欄位",
selectAllColumnsDetail: "一鍵勾選全部欄位",
deselectAllColumns: "取消全選",
selectColumnsBeforeInsert: "請至少選擇一個欄位",
tableAlias: "資料表別名",
functionDescriptions: {
@@ -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<string>;
}
interface SelectAllHarness {
toggleAllBatchColumnSelection: (view: unknown, sessionKey: string) => void;
setSession: (session: Session | null) => void;
getSelectedKeys: () => Set<string>;
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<SelectAllHarness, "refreshCalls">;
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();
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB