feat(data-grid): hide selected columns from header context menu

This commit is contained in:
Abeautifulsnow
2026-09-14 12:39:06 +08:00
committed by GitHub
parent 974848d6e2
commit 638aa48f57
22 changed files with 460 additions and 16 deletions
+54 -1
View File
@@ -1933,7 +1933,8 @@ const {
filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
hideColumns: hideColumnsInLayout,
showAllColumns: showAllColumnsInLayout,
invertColumnVisibility,
showColumn,
persistColumnOrder,
@@ -2294,6 +2295,47 @@ function unfreezeAllColumns() {
applyColumnOrderChange(unfreezeAllColumnsInLayout);
}
// 隐藏列后列宽/滚动范围都会缩短:让滚动条贴回新的最大偏移,避免右侧出现空白槽。
function clampGridHorizontalScroll() {
const scroller = gridScrollerElement();
if (!scroller) return;
const previousLeft = scroller.scrollLeft;
clampGridScrollerBounds(scroller); // 复用既有边界收敛,保持单一事实来源
if (scroller.scrollLeft === previousLeft) return; // 未越界,无需同步
updateGridHorizontalViewport(scroller);
if (headerRef.value) headerRef.value.scrollLeft = scroller.scrollLeft;
}
// 批量隐藏(表头右键菜单):一次布局提交 + 一次持久化,并把可见索引变化交给
// applyColumnOrderChange 处理,随后清空单元格选区、重测列宽并收敛横向滚动。
function hideColumns(columnIndexes: number[]) {
if (columnIndexes.length === 0) return;
applyColumnOrderChange(() => hideColumnsInLayout(columnIndexes));
clearCellSelection();
void nextTick(() => {
scheduleColumnLayoutRefresh();
clampGridHorizontalScroll();
});
}
function hideContextColumn() {
const columnIndex = contextHeaderColumnIndex.value;
if (columnIndex === null || columnIndex < 0) return;
hideColumns([columnIndex]);
}
function hideSelectedColumns() {
const actualColumnIndexes = selectedVisibleColumnIndexes()
.map((visibleColIdx) => visibleColumnIndexes.value[visibleColIdx])
.filter((index): index is number => index !== undefined);
hideColumns(actualColumnIndexes);
}
function showAllColumns() {
applyColumnOrderChange(showAllColumnsInLayout);
void nextTick(scheduleColumnLayoutRefresh);
}
// --- 表头拖拽进 SQL 编辑器:目标导向模式切换的控制器 ---
// 按选择顺序(Set 插入序)取列名;被拖列未选中时仅拖该列。
function columnReferenceDragNames(draggedVisibleColIdx: number): string[] | null {
@@ -11104,6 +11146,7 @@ defineExpose({
filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
hideColumns,
showAllColumns,
invertColumnVisibility,
hasCustomColumnOrder,
@@ -11289,6 +11332,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
const row = contextRowItem.value;
const rowLabels = rowActionLabels();
const hasEditableSelection = selectionHasEditableCells();
const selectedColumnCount = selectedVisibleColumnIndexes().length;
const gridSnapshotContext = contextHeaderColumn.value && hasColumnSelection.value ? "columns" : contextCell.value?.col === -1 && affectedRowIds().length > 0 ? "rows" : contextCell.value && hasCellSelection.value && selectedCellMatrix.value ? "cells" : null;
const previewItems: ContextMenuItem[] = [];
if (!contextHeaderColumn.value && contextCell.value) {
@@ -11320,6 +11364,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
frozenColumnCount: frozenColumnCount.value,
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
hasColumnSelection: hasColumnSelection.value,
selectedColumnCount,
visibleColumnCount: visibleColumnCount.value,
hiddenColumnCount: hiddenColumnCount.value,
labels: {
copyName:
selectedColumnNamesForCopy.value.length > 1
@@ -11338,6 +11385,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
freezeToColumn: t("grid.freezeToColumn"),
freezeSelectedColumns: t("grid.freezeSelectedColumns"),
unfreezeColumns: t("grid.unfreezeColumns"),
hideColumn: t("grid.hideColumn"),
hideSelectedColumns: t("grid.hideSelectedColumns", { count: selectedColumnCount }),
showAllColumnsMenu: t("grid.showAllColumnsMenu"),
},
icons: {
copy: Copy,
@@ -11368,6 +11418,9 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
unfreezeAllColumns();
clearCellSelection();
},
hideColumn: hideContextColumn,
hideSelectedColumns,
showAllColumnsMenu: showAllColumns,
},
filterSubmenu: filterSubmenu(),
}),
@@ -70,6 +70,7 @@ function createGrid(itemCount: number) {
return normalizedSearch ? options.filter((option) => option.column.toLowerCase().includes(normalizedSearch)) : options;
},
toggleColumnVisibility,
hideColumns: vi.fn(),
showAllColumns: vi.fn(),
invertColumnVisibility: vi.fn(),
hasCustomColumnOrder: false,
@@ -13,6 +13,7 @@ export interface DataGridColumnLayoutHandle {
orderedColumnLayoutOptions: readonly DataGridColumnLayoutOption[];
filteredColumnLayoutOptions: (search: string) => DataGridColumnLayoutOption[];
toggleColumnVisibility: (columnIndex: number) => void;
hideColumns: (columnIndexes: number[]) => void;
showAllColumns: () => void;
invertColumnVisibility: () => void;
hasCustomColumnOrder: boolean;
@@ -232,6 +232,83 @@ describe("useDataGridColumnLayout", () => {
expect(JSON.parse(localStorage.getItem("dbx-data-grid-column-layout:visibility-null-column-layout")!)).toMatchObject({ hiddenKeys: ["empty\0\0"] });
});
it("hides a batch of columns in one commit and persists the hidden keys", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email", "phone"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2, 3]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0", "phone\0\0"]),
layoutScopeKey: ref("batch-hide-layout"),
tableScopeKey: ref(""),
}),
)!;
state.hideColumns([1, 2]);
expect(state.visibleColumnIndexes.value).toEqual([0, 3]);
expect(state.hiddenColumnCount.value).toBe(2);
// 重复索引与已隐藏索引不会改变结果(幂等)
state.hideColumns([1, 2]);
expect(state.visibleColumnIndexes.value).toEqual([0, 3]);
state.showAllColumns();
expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2, 3]);
state.hideColumns([3]);
state.toggleColumnVisibility(0);
scope.stop();
expect(loadDataGridColumnLayout("batch-hide-layout")?.hiddenKeys).toEqual(["id\0\0", "phone\0\0"]);
});
it("keeps at least one column visible when a batch would hide every column", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("batch-hide-last-layout"),
tableScopeKey: ref(""),
}),
)!;
state.hideColumns([1, 2]);
expect(state.visibleColumnIndexes.value).toEqual([0]);
// 只剩一列可见时再隐藏它:保持不变,不会变成空网格
state.hideColumns([0]);
expect(state.visibleColumnIndexes.value).toEqual([0]);
scope.stop();
});
it("treats an empty batch hide as a no-op", () => {
const scope = effectScope();
const state = scope.run(() =>
useDataGridColumnLayoutState({
columns: ref(["id", "name", "email"]),
sourceColumns: ref(undefined),
commentByColumn: ref(new Map()),
displayableColumnIndexes: ref([0, 1, 2]),
allNullColumnIndexes: ref([]),
columnOrderKeys: ref(["id\0\0", "name\0\0", "email\0\0"]),
layoutScopeKey: ref("batch-hide-empty-layout"),
tableScopeKey: ref(""),
}),
)!;
state.hideColumns([]);
expect(state.visibleColumnIndexes.value).toEqual([0, 1, 2]);
scope.stop();
});
it("returns ordered layout options with visibility state and reorders hidden fields", () => {
const scope = effectScope();
const state = scope.run(() =>
@@ -9,19 +9,23 @@ function createSelection(options?: {
onUserCellSelection?: () => void;
shouldUpdateDraggedRowsImmediately?: () => boolean;
onDraggedRowSelectionChange?: () => void;
displayItemCount?: number;
}) {
const columns = computed(() => ["id", "name", "email"]);
const displayItems = computed(() =>
[1, 2, 3, 4].map((id, index) => ({
id,
sourceIndex: index,
data: [id, `name-${id}`, `user-${id}@example.com`],
isNew: false,
isDraft: false,
isDeleted: false,
isDirtyCol: [false, false, false],
status: "clean",
})),
Array.from({ length: options?.displayItemCount ?? 4 }, (_, index) => {
const id = index + 1;
return {
id,
sourceIndex: index,
data: [id, `name-${id}`, `user-${id}@example.com`],
isNew: false,
isDraft: false,
isDeleted: false,
isDirtyCol: [false, false, false],
status: "clean",
};
}),
);
return useDataGridSelection({
@@ -685,4 +689,40 @@ describe("useDataGridSelection", () => {
Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument });
}
});
it("selects a header column on a zero-row result", () => {
const selection = createSelection({ displayItemCount: 0 });
selection.selectColumn(1);
expect(selection.hasColumnSelection.value).toBe(true);
expect(selection.selectedColumnIndexes.value).toEqual(new Set([1]));
});
it("selects a header column range on a zero-row result", () => {
const selection = createSelection({ displayItemCount: 0 });
selection.selectColumns(0, 2);
expect(selection.hasColumnSelection.value).toBe(true);
expect(selection.selectedColumnIndexes.value).toEqual(new Set([0, 1, 2]));
});
it("merges a shift-click header selection on a zero-row result", () => {
const selection = createSelection({ displayItemCount: 0 });
selection.selectColumn(1);
selection.selectColumn(3, rowEvent({ shift: true }));
expect(selection.selectedColumnIndexes.value).toEqual(new Set([1, 2, 3]));
});
it("keeps discrete cell selection empty when selecting header columns on a zero-row result", () => {
const selection = createSelection({ displayItemCount: 0 });
selection.selectColumn(1);
expect(selection.selectedCellKeys.value).toEqual(new Set());
expect(selection.hasCellSelection.value).toBe(false);
});
});
@@ -14,7 +14,7 @@ import {
type TableDataGridColumnOrderChangedDetail,
} from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems, type DataGridColumnLookupItem } from "@/lib/dataGrid/dataGridColumnLookup";
import { hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
import { hiddenColumnIndexesAfterHiding, hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "@/lib/dataGrid/dataGridColumnVisibility";
export type RenderedDataGridColumn = {
visibleColIdx: number;
@@ -212,6 +212,24 @@ export function useDataGridColumnLayoutState(options: {
persistHiddenColumnKeys();
}
// 批量隐藏:一次 hiddenColumnIndexes 提交、一次持久化,供表头右键菜单使用。
function hideColumns(columnIndexes: Iterable<number>) {
const requestedIndexes = [...columnIndexes].filter((index) => Number.isInteger(index) && index >= 0);
if (requestedIndexes.length === 0) return;
hiddenColumnIndexes.value = hiddenColumnIndexesAfterHiding({
columnIndexes: requestedIndexes,
hiddenIndexes: hiddenColumnIndexes.value,
availableIndexes: toValue(options.displayableColumnIndexes),
});
// hiddenColumnIndexesAfterHiding 只做加法(绝不删列),且 invariant
// autoHiddenNullColumnIndexes 始终是 hiddenColumnIndexes 的子集,由
// applyNullColumnVisibility / showColumn / toggleColumnVisibility 共同维护,
// 所以这里不需要清理 autoHiddenNullColumnIndexes(与之等价的剪枝循环恒不可达)。
// 将来若允许传入「已隐藏」的列,正确做法与剪枝相反:该列属于手动隐藏,
// 必须从 autoHiddenNullColumnIndexes 中移除,而不是保留。
persistHiddenColumnKeys();
}
function showAllColumns() {
hiddenColumnIndexes.value = new Set();
autoHiddenNullColumnIndexes.value = new Set();
@@ -410,6 +428,7 @@ export function useDataGridColumnLayoutState(options: {
filteredColumnLayoutOptions,
isColumnVisible,
toggleColumnVisibility,
hideColumns,
showAllColumns,
invertColumnVisibility,
showColumn,
@@ -224,7 +224,9 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
function selectColumns(startCol: number, endCol: number, options?: { merge?: boolean }) {
const normalizedColumns = normalizeSelectedColumnIndexes(Array.from({ length: Math.abs(endCol - startCol) + 1 }, (_, index) => Math.min(startCol, endCol) + index));
if (normalizedColumns.length === 0 || displayItems.value.length <= 0) return;
// Column selection is column-based and deliberately independent of the row count, so an
// empty result (0 rows, N columns) keeps header multi-select working.
if (normalizedColumns.length === 0) return;
clearRowSelection();
selectedCellKeys.value = new Set();
selectionAnchor.value = null;
+3
View File
@@ -2019,6 +2019,9 @@ export default withEnglishFallback({
freezeToColumn: "Bu sütuna qədər sabitlə",
freezeSelectedColumns: "Seçilmiş sütunları sabitlə",
unfreezeColumns: "Sütunların sabitlənməsini ləğv et",
hideColumn: "Bu sütunu gizlət",
hideSelectedColumns: "Seçilmiş sütunları gizlət ({count})",
showAllColumnsMenu: "Bütün sütunları göstər",
pasted: "Yerləşdirildi!",
batchAppendPasteNotEditable: "Bu nəticəni redaktə etmək mümkün deyil.",
batchAppendPasteInvalidTarget: "Yerləşdirməzdən əvvəl boş yeni sətrin nömrəsini seçin.",
+3
View File
@@ -2136,6 +2136,9 @@ export default {
freezeToColumn: "Freeze to This Column",
freezeSelectedColumns: "Freeze Selected Columns",
unfreezeColumns: "Unfreeze Columns",
hideColumn: "Hide This Column",
hideSelectedColumns: "Hide Selected Columns ({count})",
showAllColumnsMenu: "Show All Columns",
pasted: "Pasted!",
batchAppendPasteNotEditable: "This result cannot be edited.",
batchAppendPasteInvalidTarget: "Select the row number of an empty new row before pasting.",
+3
View File
@@ -1987,6 +1987,9 @@ export default withEnglishFallback({
freezeToColumn: "Inmovilizar hasta esta columna",
freezeSelectedColumns: "Inmovilizar columnas seleccionadas",
unfreezeColumns: "Desinmovilizar columnas",
hideColumn: "Ocultar esta columna",
hideSelectedColumns: "Ocultar columnas seleccionadas ({count})",
showAllColumnsMenu: "Mostrar todas las columnas",
pasted: "¡Pegado!",
search: "Buscar...",
searchTruncatedValuesHint: "Algunos valores largos de texto/JSON están truncados para su visualización; la búsqueda solo cubre el contenido cargado y puede encontrar menos coincidencias que la consulta SQL.",
+3
View File
@@ -1985,6 +1985,9 @@ export default withEnglishFallback({
freezeToColumn: "Blocca fino a questa colonna",
freezeSelectedColumns: "Blocca colonne selezionate",
unfreezeColumns: "Sblocca colonne",
hideColumn: "Nascondi questa colonna",
hideSelectedColumns: "Nascondi colonne selezionate ({count})",
showAllColumnsMenu: "Mostra tutte le colonne",
pasted: "Incollato!",
search: "Cerca...",
searchTruncatedValuesHint: "Alcuni valori di testo/JSON lunghi sono troncati per la visualizzazione; la ricerca copre solo il contenuto caricato e può trovare meno corrispondenze della query SQL.",
+3
View File
@@ -1997,6 +1997,9 @@ export default withEnglishFallback({
freezeToColumn: "この列まで固定",
freezeSelectedColumns: "選択列を固定",
unfreezeColumns: "列の固定を解除",
hideColumn: "この列を非表示",
hideSelectedColumns: "選択した列を非表示 ({count})",
showAllColumnsMenu: "すべての列を表示",
pasted: "貼り付けました!",
search: "検索...",
searchTruncatedValuesHint: "一部の長いテキスト/JSON 値は表示用に切り詰められています。検索は読み込み済みの表示内容のみが対象で、SQL クエリより一致数が少なくなることがあります。",
+3
View File
@@ -1977,6 +1977,9 @@ export default withEnglishFallback({
freezeToColumn: "이 컬럼까지 고정",
freezeSelectedColumns: "선택한 컬럼 고정",
unfreezeColumns: "컬럼 고정 해제",
hideColumn: "이 컬럼 숨기기",
hideSelectedColumns: "선택한 컬럼 숨기기 ({count})",
showAllColumnsMenu: "모든 컬럼 표시",
pasted: "붙여넣었습니다!",
search: "검색...",
searchTruncatedValuesHint: "일부 긴 텍스트/JSON 값은 표시를 위해 잘렸습니다. 검색은 로드된 표시 내용만 대상으로 하며 SQL 쿼리보다 일치 수가 적을 수 있습니다.",
+3
View File
@@ -1987,6 +1987,9 @@ export default withEnglishFallback({
freezeToColumn: "Congelar até esta coluna",
freezeSelectedColumns: "Congelar colunas selecionadas",
unfreezeColumns: "Descongelar colunas",
hideColumn: "Ocultar esta coluna",
hideSelectedColumns: "Ocultar colunas selecionadas ({count})",
showAllColumnsMenu: "Mostrar todas as colunas",
pasted: "Colado!",
search: "Pesquisar...",
searchTruncatedValuesHint: "Alguns valores longos de texto/JSON estão truncados para exibição; a busca cobre apenas o conteúdo carregado e pode encontrar menos correspondências que a consulta SQL.",
+3
View File
@@ -2014,6 +2014,9 @@ export default withEnglishFallback({
freezeToColumn: "Bu Sütuna Kadar Dondur",
freezeSelectedColumns: "Seçili Sütunları Dondur",
unfreezeColumns: "Sütun Dondurmayı Kaldır",
hideColumn: "Bu Sütunu Gizle",
hideSelectedColumns: "Seçili Sütunları Gizle ({count})",
showAllColumnsMenu: "Tüm Sütunları Göster",
pasted: "Yapıştırıldı!",
batchAppendPasteNotEditable: "Bu sonuç düzenlenemez.",
batchAppendPasteInvalidTarget: "Yapıştırmadan önce boş yeni bir satırın satır numarasını seçin.",
+3
View File
@@ -2051,6 +2051,9 @@ export default withEnglishFallback({
freezeToColumn: "冻结到此列",
freezeSelectedColumns: "冻结选中列",
unfreezeColumns: "取消冻结",
hideColumn: "隐藏此列",
hideSelectedColumns: "隐藏选中列({count} 列)",
showAllColumnsMenu: "显示全部列",
pasted: "已粘贴!",
batchAppendPasteNotEditable: "当前结果不可编辑。",
batchAppendPasteInvalidTarget: "请先选中一个空新增行的行号单元格,再进行粘贴。",
+3
View File
@@ -1985,6 +1985,9 @@ export default withEnglishFallback({
freezeToColumn: "凍結到此欄",
freezeSelectedColumns: "凍結選取欄",
unfreezeColumns: "取消凍結",
hideColumn: "隱藏此欄",
hideSelectedColumns: "隱藏選取欄({count} 欄)",
showAllColumnsMenu: "顯示全部欄",
pasted: "已貼上!",
search: "搜尋……",
searchTruncatedValuesHint: "部分長文字/JSON 值已截斷顯示,搜尋僅涵蓋已載入的顯示內容,符合數可能少於 SQL 查詢。",
@@ -138,4 +138,87 @@ describe("dataGridContextMenu", () => {
});
expect(rowItems.find((item) => item.label === "delete")?.variant).toBe("destructive");
});
function columnMenuItems(overrides: { hasColumnSelection?: boolean; selectedColumnCount?: number; visibleColumnCount?: number; hiddenColumnCount?: number }) {
const action = vi.fn();
const filter = createDataGridFilterSubmenu({
label: "filter",
icon,
labels: { equals: "equals", notEquals: "not equals", like: "like", notLike: "not like", lessThan: "less", greaterThan: "greater", isNull: "null", isNotNull: "not null", clear: "clear" },
apply: action,
clear: action,
});
return createDataGridColumnContextMenuItems({
headerColumn: true,
contextColumn: false,
canCopyAlterSql: false,
canFilter: false,
hasSort: false,
sortMode: "database",
contextVisibleColIdx: 0,
hasColumnSelection: false,
selectedColumnCount: 1,
visibleColumnCount: 3,
hiddenColumnCount: 0,
labels: {
copyName: "copy name",
copyNames: "copy names",
details: "details",
copyAlterSql: "alter",
databaseAscending: "db asc",
databaseDescending: "db desc",
localAscending: "local asc",
localDescending: "local desc",
clearSort: "clear sort",
freezeToColumn: "freeze to column",
freezeSelectedColumns: "freeze selected",
unfreezeColumns: "unfreeze",
hideColumn: "hide column",
hideSelectedColumns: "hide selected (3)",
showAllColumnsMenu: "show all columns",
},
icons: { copy: icon, columnDetails: icon, database: icon, ascending: icon, descending: icon, clearSort: icon },
actions: { copyName: action, copyNames: action, details: action, copyAlterSql: action, sort: action, freezeToColumn: action, freezeSelectedColumns: action, unfreezeColumns: action, hideColumn: action, hideSelectedColumns: action, showAllColumnsMenu: action },
filterSubmenu: filter,
...overrides,
});
}
it("offers hide-this-column on a header and hides the recovery entry until a column is hidden", () => {
const labels = columnMenuItems({}).map((item) => item.label);
expect(labels).toContain("hide column");
expect(labels).not.toContain("hide selected (3)");
expect(labels).not.toContain("show all columns");
});
it("offers batch hiding for a multi-column selection and shows the recovery entry when columns are hidden", () => {
const items = columnMenuItems({ hasColumnSelection: true, selectedColumnCount: 3, visibleColumnCount: 5, hiddenColumnCount: 2 });
const hideSelected = items.find((item) => item.label === "hide selected (3)");
expect(hideSelected?.disabled).toBe(false);
expect(items.map((item) => item.label)).toContain("show all columns");
});
it("disables batch hiding when the selection covers every visible column", () => {
const items = columnMenuItems({ hasColumnSelection: true, selectedColumnCount: 3, visibleColumnCount: 3 });
expect(items.find((item) => item.label === "hide selected (3)")?.disabled).toBe(true);
});
it("disables hiding this column when only one column is visible", () => {
const items = columnMenuItems({ visibleColumnCount: 1 });
const hideColumn = items.find((item) => item.label === "hide column");
expect(hideColumn).toBeDefined();
expect(hideColumn?.disabled).toBe(true);
});
it("keeps hiding this column enabled for callers that omit the visible column count", () => {
const items = columnMenuItems({ visibleColumnCount: undefined });
const hideColumn = items.find((item) => item.label === "hide column");
expect(hideColumn).toBeDefined();
expect(hideColumn?.disabled).toBe(false);
});
});
@@ -65,6 +65,34 @@ export function nextHiddenColumnIndexes(options: { columnIndex: number; hiddenIn
return next;
}
/**
* Batch counterpart of {@link nextHiddenColumnIndexes}: hides a whole set of
* columns in one deterministic step and keeps at least one column visible.
* When the batch would hide every visible column, the first currently visible
* column is kept instead (same rule as the single-column toggle, expressed once
* for a set). Duplicate or already-hidden indexes are ignored, and an empty
* batch is a no-op.
*/
export function hiddenColumnIndexesAfterHiding(options: { columnIndexes: Iterable<number>; hiddenIndexes: ReadonlySet<number>; availableIndexes: readonly number[] }): Set<number> {
const next = new Set(options.hiddenIndexes);
const available = new Set(options.availableIndexes);
const visibleIndexes = options.availableIndexes.filter((index) => !next.has(index));
const requestedIndexes = [...new Set(options.columnIndexes)].filter((index) => available.has(index) && !next.has(index));
if (requestedIndexes.length === 0) return next;
if (visibleIndexes.length - requestedIndexes.length >= 1) {
for (const index of requestedIndexes) next.add(index);
return next;
}
// 会隐藏全部可见列:保留最前面的一个可见列,其余全部隐藏。
const keptIndex = visibleIndexes[0];
for (const index of visibleIndexes) {
if (index !== keptIndex) next.add(index);
}
return next;
}
export function invertedHiddenColumnIndexes(availableIndexes: number[], hiddenIndexes: ReadonlySet<number>): Set<number> {
const next = new Set(availableIndexes.filter((index) => !hiddenIndexes.has(index)));
if (next.size === availableIndexes.length && availableIndexes.length > 0) {
@@ -81,9 +81,30 @@ export function createDataGridColumnContextMenuItems(options: {
frozenColumnCount?: number;
contextVisibleColIdx?: number;
hasColumnSelection?: boolean;
labels: Record<"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort" | "freezeToColumn" | "freezeSelectedColumns" | "unfreezeColumns", string>;
/** 显式多选的可见列数量;为 undefined 时按 0 处理。 */
selectedColumnCount?: number;
/** 当前可见列总数,用于判断「选中列覆盖全部可见列」而禁用批量隐藏。 */
visibleColumnCount?: number;
/** 当前被隐藏的列数量,用于决定是否显示「显示全部列」。 */
hiddenColumnCount?: number;
labels: Record<
"copyName" | "copyNames" | "details" | "copyAlterSql" | "databaseAscending" | "databaseDescending" | "localAscending" | "localDescending" | "clearSort" | "freezeToColumn" | "freezeSelectedColumns" | "unfreezeColumns" | "hideColumn" | "hideSelectedColumns" | "showAllColumnsMenu",
string
>;
icons: Pick<DataGridContextMenuIcons, "copy" | "columnDetails" | "database" | "ascending" | "descending" | "clearSort">;
actions: { copyName: () => void; copyNames: () => void; details: () => void; copyAlterSql: () => void; sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void; freezeToColumn: () => void; freezeSelectedColumns: () => void; unfreezeColumns: () => void };
actions: {
copyName: () => void;
copyNames: () => void;
details: () => void;
copyAlterSql: () => void;
sort: (direction: "asc" | "desc" | null, mode: "database" | "local") => void;
freezeToColumn: () => void;
freezeSelectedColumns: () => void;
unfreezeColumns: () => void;
hideColumn: () => void;
hideSelectedColumns: () => void;
showAllColumnsMenu: () => void;
};
filterSubmenu: DataGridContextMenuItem;
}): DataGridContextMenuItem[] {
const items: DataGridContextMenuItem[] = [];
@@ -108,6 +129,18 @@ export function createDataGridColumnContextMenuItems(options: {
}
if (options.contextVisibleColIdx !== undefined) {
items.push({ label: "", separator: true });
const selectedColumnCount = options.selectedColumnCount ?? 0;
const visibleColumnCount = options.visibleColumnCount ?? 0;
// 只剩一列时隐藏会被 helper 拒绝;用 disabled 明确表达,而不是静默无效。
// 这里读 options.visibleColumnCount 而非本地默认值:字段可选,未传的调用方不应被误禁用。
items.push({ label: options.labels.hideColumn, action: options.actions.hideColumn, disabled: options.visibleColumnCount !== undefined && options.visibleColumnCount <= 1 });
// 仅在多选(>1)时提供批量隐藏;选中列覆盖全部可见列时禁用,而不是悄悄少隐藏一列。
if (options.hasColumnSelection && selectedColumnCount > 1) {
items.push({ label: options.labels.hideSelectedColumns, action: options.actions.hideSelectedColumns, disabled: visibleColumnCount > 0 && selectedColumnCount >= visibleColumnCount });
}
if ((options.hiddenColumnCount ?? 0) > 0) {
items.push({ label: options.labels.showAllColumnsMenu, action: options.actions.showAllColumnsMenu });
}
if ((options.frozenColumnCount ?? 0) > 0) {
items.push({ label: options.labels.unfreezeColumns, action: options.actions.unfreezeColumns });
}
+60 -1
View File
@@ -1,7 +1,18 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildDataGridColumnLookupItems, filterDataGridColumnLookupItems } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnLookup.ts";
import { allNullColumnIndexes, filterColumnVisibilityOptions, hiddenColumnIndexesForKeys, hiddenColumnIndexesWithAllNullColumns, hiddenColumnKeysForIndexes, invertedHiddenColumnIndexes, nextHiddenColumnIndexes, removeAutoHiddenColumnIndexes, visibleColumnIndexesForFilter } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts";
import {
allNullColumnIndexes,
filterColumnVisibilityOptions,
hiddenColumnIndexesAfterHiding,
hiddenColumnIndexesForKeys,
hiddenColumnIndexesWithAllNullColumns,
hiddenColumnKeysForIndexes,
invertedHiddenColumnIndexes,
nextHiddenColumnIndexes,
removeAutoHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "../../apps/desktop/src/lib/dataGrid/dataGridColumnVisibility.ts";
test("filters column visibility options by trimmed case-insensitive text", () => {
const options = filterColumnVisibilityOptions(["id", "created_at", "CustomerName"], " NAME ");
@@ -93,6 +104,54 @@ test("inverts hidden column indexes", () => {
assert.deepEqual([...hidden].sort(), [0, 2]);
});
test("hides a batch of columns in one step", () => {
const hidden = hiddenColumnIndexesAfterHiding({
columnIndexes: [1, 2],
hiddenIndexes: new Set(),
availableIndexes: [0, 1, 2, 3],
});
assert.deepEqual([...hidden].sort(), [1, 2]);
});
test("batch hiding is idempotent for duplicates and already hidden columns", () => {
const hidden = hiddenColumnIndexesAfterHiding({
columnIndexes: [1, 1, 2, 2],
hiddenIndexes: new Set([2]),
availableIndexes: [0, 1, 2, 3],
});
assert.deepEqual([...hidden].sort(), [1, 2]);
});
test("batch hiding ignores empty input and unavailable indexes", () => {
const empty = hiddenColumnIndexesAfterHiding({ columnIndexes: [], hiddenIndexes: new Set([1]), availableIndexes: [0, 1, 2] });
assert.deepEqual([...empty], [1]);
const unavailable = hiddenColumnIndexesAfterHiding({ columnIndexes: [9], hiddenIndexes: new Set(), availableIndexes: [0, 1, 2] });
assert.deepEqual([...unavailable], []);
});
test("batch hiding keeps the last visible column", () => {
const noneLeft = hiddenColumnIndexesAfterHiding({
columnIndexes: [2],
hiddenIndexes: new Set([0, 1]),
availableIndexes: [0, 1, 2],
});
assert.deepEqual([...noneLeft].sort(), [0, 1]);
});
test("batch hiding every visible column keeps the first one visible", () => {
const hidden = hiddenColumnIndexesAfterHiding({
columnIndexes: [0, 1, 2],
hiddenIndexes: new Set(),
availableIndexes: [0, 1, 2],
});
assert.deepEqual([...hidden].sort(), [1, 2]);
});
test("keeps one column visible when inverting all visible columns", () => {
const hidden = invertedHiddenColumnIndexes([0, 1, 2], new Set());
+18
View File
@@ -45,6 +45,24 @@ test("context-menu invalidation clears cell and header targets together", () =>
assert.match(handler, /contextHeaderVisibleColIdx\.value = null;/);
});
test("header context menu wires batch hide and show-all to the column layout pipeline", () => {
assert.match(dataGridSource, /hideColumn: hideContextColumn,/);
assert.match(dataGridSource, /hideSelectedColumns,/);
assert.match(dataGridSource, /showAllColumnsMenu: showAllColumns,/);
assert.match(dataGridSource, /hiddenColumnCount: hiddenColumnCount\.value,/);
const hideWrapper = dataGridSource.match(/function hideColumns\(columnIndexes: number\[\]\) \{[^]*?\n\}/)?.[0] ?? "";
assert.match(hideWrapper, /applyColumnOrderChange\(\(\) => hideColumnsInLayout\(columnIndexes\)\)/);
assert.match(hideWrapper, /clearCellSelection\(\);/);
assert.match(hideWrapper, /clampGridHorizontalScroll\(\)/);
// 显示全部列也走 applyColumnOrderChange,隐藏期间产生的可见索引偏移会被重新对齐。
const showAllWrapper = dataGridSource.match(/function showAllColumns\(\) \{[^]*?\n\}/)?.[0] ?? "";
assert.match(showAllWrapper, /applyColumnOrderChange\(showAllColumnsInLayout\)/);
});
test("select-all context menus invalidate a stale specialized target", () => {
const header = dataGridSource.match(/<div\s+class="data-grid-header-cell shrink-0 px-2 py-1\.5[^]*?@click="selectAllCells"[^]*?>/u)?.[0] ?? "";