mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
feat(data-grid): hide selected columns from header context menu
This commit is contained in:
@@ -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(),
|
||||
}),
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1997,6 +1997,9 @@ export default withEnglishFallback({
|
||||
freezeToColumn: "この列まで固定",
|
||||
freezeSelectedColumns: "選択列を固定",
|
||||
unfreezeColumns: "列の固定を解除",
|
||||
hideColumn: "この列を非表示",
|
||||
hideSelectedColumns: "選択した列を非表示 ({count})",
|
||||
showAllColumnsMenu: "すべての列を表示",
|
||||
pasted: "貼り付けました!",
|
||||
search: "検索...",
|
||||
searchTruncatedValuesHint: "一部の長いテキスト/JSON 値は表示用に切り詰められています。検索は読み込み済みの表示内容のみが対象で、SQL クエリより一致数が少なくなることがあります。",
|
||||
|
||||
@@ -1977,6 +1977,9 @@ export default withEnglishFallback({
|
||||
freezeToColumn: "이 컬럼까지 고정",
|
||||
freezeSelectedColumns: "선택한 컬럼 고정",
|
||||
unfreezeColumns: "컬럼 고정 해제",
|
||||
hideColumn: "이 컬럼 숨기기",
|
||||
hideSelectedColumns: "선택한 컬럼 숨기기 ({count})",
|
||||
showAllColumnsMenu: "모든 컬럼 표시",
|
||||
pasted: "붙여넣었습니다!",
|
||||
search: "검색...",
|
||||
searchTruncatedValuesHint: "일부 긴 텍스트/JSON 값은 표시를 위해 잘렸습니다. 검색은 로드된 표시 내용만 대상으로 하며 SQL 쿼리보다 일치 수가 적을 수 있습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -2051,6 +2051,9 @@ export default withEnglishFallback({
|
||||
freezeToColumn: "冻结到此列",
|
||||
freezeSelectedColumns: "冻结选中列",
|
||||
unfreezeColumns: "取消冻结",
|
||||
hideColumn: "隐藏此列",
|
||||
hideSelectedColumns: "隐藏选中列({count} 列)",
|
||||
showAllColumnsMenu: "显示全部列",
|
||||
pasted: "已粘贴!",
|
||||
batchAppendPasteNotEditable: "当前结果不可编辑。",
|
||||
batchAppendPasteInvalidTarget: "请先选中一个空新增行的行号单元格,再进行粘贴。",
|
||||
|
||||
@@ -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
@@ -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
@@ -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] ?? "";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user