feat(grid): show average for numeric selections

Closes #4929
This commit is contained in:
t8y2 committed 2026-08-24 22:53:11 +00:00
1 parent e2fa810f2e
commit b3e8709272
13 files changed
+133 -10

No files matched your search

@@ -253,7 +253,7 @@ import { allNullColumnIndexes } from "@/lib/dataGrid/dataGridColumnVisibility";
import { buildDataGridColumnLookupItems, dataGridColumnCommentFor, filterDataGridColumnLookupItems } from "@/lib/dataGrid/dataGridColumnLookup";
import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrder";
import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { captureDataGridSelection, restoreDataGridSelection, type CaptureDataGridSelectionOptions, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence";
import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames";
import {
@@ -5554,12 +5554,7 @@ const multiRowCount = computed(() => {
const selectionSummary = computed(() => {
if (!hasCellSelection.value) return null;
if (isSelectingCells.value) {
return {
cellCount: selectedCellCount.value,
rowCount: multiRowCount.value,
numericCount: 0,
sum: 0,
};
return createPendingSelectionSummary(selectedCellCount.value, multiRowCount.value);
}
return summarizeSelection(selectedCells.value);
});
@@ -5567,8 +5562,11 @@ const selectionSummarySumText = computed(() => {
if (isSelectingCells.value) return "…";
const summary = selectionSummary.value;
if (!summary) return "0";
const sum = Object.is(summary.sum, -0) ? 0 : summary.sum;
return Number.isInteger(sum) ? String(sum) : sum.toLocaleString(undefined, { maximumFractionDigits: 12 });
return formatSelectionAggregate(summary.sum);
});
const selectionSummaryAverageText = computed(() => {
if (isSelectingCells.value) return "…";
return formatSelectionAverage(selectionSummary.value?.average);
});
const isMultiRow = computed(() => multiRowCount.value > 1);
@@ -13587,6 +13585,7 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
:pagination-enabled="paginationEnabled"
:selection-summary="selectionSummary"
:selection-summary-sum-text="selectionSummarySumText"
:selection-summary-average-text="selectionSummaryAverageText"
:loading="gridPaginationBusy"
:infinite-scroll-enabled="infiniteScrollEnabled"
:infinite-scroll-all-loaded="infiniteScrollAllLoaded"
@@ -16,6 +16,7 @@ const props = withDefaults(
paginationEnabled?: boolean;
selectionSummary: { cellCount: number; rowCount: number } | null;
selectionSummarySumText: string;
selectionSummaryAverageText: string;
loading: boolean;
infiniteScrollEnabled: boolean;
infiniteScrollAllLoaded: boolean;
@@ -86,6 +87,7 @@ function handlePageInputKeydown(event: KeyboardEvent) {
<div class="flex min-w-max items-center justify-end gap-1">
<div v-if="selectionSummary" class="flex shrink-0 items-center gap-3 tabular-nums">
<span class="shrink-0">{{ t("grid.selectionSum", { value: selectionSummarySumText }) }}</span>
<span class="shrink-0">{{ t("grid.selectionAverage", { value: selectionSummaryAverageText }) }}</span>
<div class="flex shrink-0 items-center gap-1">
<span class="shrink-0">{{ t("grid.selectionCells", { count: selectionSummary.cellCount }) }}</span>
<span class="shrink-0">{{ t("grid.rows", { count: selectionSummary.rowCount }) }}</span>
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { dispatch, findAll, findOne, hostText, mountComponent } from "./vueHostHarness";
@@ -98,6 +99,8 @@ import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
function detail(patch: Partial<DataGridCellDetail> = {}): DataGridCellDetail {
return {
rowNumber: 1,
@@ -184,6 +187,42 @@ describe("DataGridSearchBar", () => {
});
describe("DataGridPagination", () => {
it("keeps drag summaries count-only and wires the derived average without materializing selected cells", () => {
const summaryStart = dataGridSource.indexOf("const selectionSummary = computed");
const pendingSummary = dataGridSource.indexOf("createPendingSelectionSummary(selectedCellCount.value, multiRowCount.value)", summaryStart);
const materializedSummary = dataGridSource.indexOf("summarizeSelection(selectedCells.value)", summaryStart);
expect(summaryStart).toBeGreaterThan(-1);
expect(pendingSummary).toBeGreaterThan(summaryStart);
expect(materializedSummary).toBeGreaterThan(pendingSummary);
expect(dataGridSource).toContain('const selectionSummaryAverageText = computed(() => {\n if (isSelectingCells.value) return "…";');
expect(dataGridSource).toContain(':selection-summary-average-text="selectionSummaryAverageText"');
});
it("shows average beside the existing selection summary values", () => {
const mounted = mountComponent(DataGridPagination, {
selectionSummary: { cellCount: 4, rowCount: 2 },
selectionSummarySumText: "10",
selectionSummaryAverageText: "2.5",
loading: false,
infiniteScrollEnabled: false,
infiniteScrollAllLoaded: false,
pageSize: 100,
customPageSizeInput: "",
pageSizeMenuItems: [],
exportMenuItems: [],
currentPage: 1,
canGoNextPage: false,
canJumpLastPage: false,
});
const summaryText = hostText(mounted.root);
expect(summaryText).toContain("grid.selectionSum");
expect(summaryText).toContain("grid.selectionAverage");
expect(summaryText).toContain("grid.selectionCells");
expect(summaryText).toContain("grid.rows");
});
it("enforces first/previous/next/last disabled boundaries", async () => {
const firstPage = vi.fn();
const previousPage = vi.fn();
@@ -192,6 +231,7 @@ describe("DataGridPagination", () => {
const mounted = mountComponent(DataGridPagination, {
selectionSummary: null,
selectionSummarySumText: "",
selectionSummaryAverageText: "",
loading: false,
infiniteScrollEnabled: false,
infiniteScrollAllLoaded: false,
@@ -236,6 +276,7 @@ describe("DataGridPagination", () => {
const mounted = mountComponent(DataGridPagination, {
selectionSummary: null,
selectionSummarySumText: "",
selectionSummaryAverageText: "",
loading: false,
infiniteScrollEnabled: false,
infiniteScrollAllLoaded: false,
@@ -278,6 +319,7 @@ describe("DataGridPagination", () => {
paginationEnabled: false,
selectionSummary: null,
selectionSummarySumText: "",
selectionSummaryAverageText: "",
loading: false,
infiniteScrollEnabled: false,
infiniteScrollAllLoaded: false,
+1
View File
@@ -1603,6 +1603,7 @@ export default {
copyColumnNamesPreview: "Preview",
copyAll: "Copy All (TSV)",
selectionSum: "SUM: {value}",
selectionAverage: "AVG: {value}",
selectionCells: "{count} cells",
clearSelection: "Clear Selection",
export: "Export",
+1
View File
@@ -1493,6 +1493,7 @@ export default withEnglishFallback({
copyColumnNamesPreview: "Vista previa",
copyAll: "Copiar todo (TSV)",
selectionSum: "SUM: {value}",
selectionAverage: "AVG: {value}",
selectionCells: "{count} celdas",
clearSelection: "Limpiar selección",
export: "Exportar",
+1
View File
@@ -1491,6 +1491,7 @@ export default withEnglishFallback({
copyColumnNamesPreview: "Anteprima",
copyAll: "Copia Tutto (TSV)",
selectionSum: "SUM: {value}",
selectionAverage: "AVG: {value}",
selectionCells: "{count} celle",
clearSelection: "Cancella Selezione",
export: "Esporta",
+1
View File
@@ -1511,6 +1511,7 @@ export default withEnglishFallback({
copyColumnNamesPreview: "プレビュー",
copyAll: "すべてコピー (TSV)",
selectionSum: "SUM: {value}",
selectionAverage: "AVG: {value}",
selectionCells: "{count} 個のセル",
clearSelection: "選択をクリア",
export: "エクスポート",
+1
View File
@@ -1483,6 +1483,7 @@ export default withEnglishFallback({
copyColumnNamesPreview: "미리보기",
copyAll: "모두 복사 (TSV)",
selectionSum: "합계: {value}",
selectionAverage: "평균: {value}",
selectionCells: "{count}개 셀",
clearSelection: "선택 해제",
export: "내보내기",
+1
View File
@@ -1493,6 +1493,7 @@ export default withEnglishFallback({
copyColumnNamesPreview: "Pré-visualização",
copyAll: "Copiar Tudo (TSV)",
selectionSum: "SUM: {value}",
selectionAverage: "AVG: {value}",
selectionCells: "{count} células",
clearSelection: "Limpar Seleção",
export: "Exportar",
+1
View File
@@ -1527,6 +1527,7 @@ export default withEnglishFallback({
copyColumnNamesPreview: "预览",
copyAll: "复制全部 (TSV)",
selectionSum: "SUM: {value}",
selectionAverage: "AVG: {value}",
selectionCells: "{count} 个单元",
clearSelection: "清除选区",
export: "导出",
+1
View File
@@ -1492,6 +1492,7 @@ export default withEnglishFallback({
copyColumnNamesPreview: "預覽",
copyAll: "複製全部 (TSV)",
selectionSum: "SUM: {value}",
selectionAverage: "AVG: {value}",
selectionCells: "{count} 個儲存格",
clearSelection: "清除選區",
export: "匯出",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { dedupeColumnIndexes, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { createPendingSelectionSummary, dedupeColumnIndexes, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection";
describe("gridSelection", () => {
it("summarizes empty selections", () => {
@@ -8,6 +8,7 @@ describe("gridSelection", () => {
rowCount: 0,
numericCount: 0,
sum: 0,
average: null,
});
});
@@ -25,6 +26,7 @@ describe("gridSelection", () => {
rowCount: 2,
numericCount: 4,
sum: 10,
average: 2.5,
});
});
@@ -42,9 +44,58 @@ describe("gridSelection", () => {
rowCount: 2,
numericCount: 3,
sum: 105.5,
average: 105.5 / 3,
});
});
it("uses the same numeric participation rules for sum and average", () => {
expect(
summarizeSelection({
columns: ["value"],
rows: [[null], [true], [false], [""], [" "], ["not a number"], [Number.NaN], [Number.POSITIVE_INFINITY]],
}),
).toEqual({
cellCount: 8,
rowCount: 8,
numericCount: 0,
sum: 0,
average: null,
});
});
it.each([
["single cell", [[8]], 1, 1, 8],
["row", [[1, 3]], 2, 1, 2],
["column", [[1], [3]], 2, 2, 2],
["range", [[1, 2], [3, 4]], 4, 2, 2.5],
] as const)("averages a %s selection without changing its counts", (_label, rows, cellCount, rowCount, average) => {
expect(summarizeSelection({ columns: [], rows: rows.map((row) => [...row]) })).toMatchObject({ cellCount, rowCount, average });
});
it("does not expose an infinite average when a finite-value sum overflows", () => {
expect(summarizeSelection({ columns: ["value"], rows: [[Number.MAX_VALUE], [Number.MAX_VALUE]] })).toMatchObject({
numericCount: 2,
sum: Number.POSITIVE_INFINITY,
average: null,
});
});
it("keeps pending summaries count-only and formats sum and average identically", () => {
expect(createPendingSelectionSummary(1_000_000, 100_000)).toEqual({
cellCount: 1_000_000,
rowCount: 100_000,
numericCount: 0,
sum: 0,
average: null,
});
expect(formatSelectionAggregate(-0, "en-US")).toBe("0");
expect(formatSelectionAggregate(1 / 3, "en-US")).toBe("0.333333333333");
expect(formatSelectionAggregate(2.5, "en-US")).toBe("2.5");
expect(formatSelectionAverage(1 / 3, "en-US")).toBe("0.333333333333");
expect(formatSelectionAverage(null, "en-US")).toBe("—");
expect(formatSelectionAverage(Number.POSITIVE_INFINITY, "en-US")).toBe("—");
});
it("dedupeColumnIndexes preserves the first visible occurrence instead of sorting", () => {
// After drag-reordering, visible order is [2, 0, 1]; extraction must keep it,
// not revert to numeric [0, 1, 2].
@@ -27,6 +27,7 @@ export interface SelectionSummary {
rowCount: number;
numericCount: number;
sum: number;
average: number | null;
}
export function parseClipboardTable(text: string): string[][] {
@@ -123,5 +124,25 @@ export function summarizeSelection(selection: SelectionData): SelectionSummary {
rowCount: selection.rows.length,
numericCount,
sum,
average: numericCount > 0 && Number.isFinite(sum) ? sum / numericCount : null,
};
}
export function createPendingSelectionSummary(cellCount: number, rowCount: number): SelectionSummary {
return {
cellCount,
rowCount,
numericCount: 0,
sum: 0,
average: null,
};
}
export function formatSelectionAggregate(value: number, locales?: Intl.LocalesArgument): string {
const normalized = Object.is(value, -0) ? 0 : value;
return Number.isInteger(normalized) ? String(normalized) : normalized.toLocaleString(locales, { maximumFractionDigits: 12 });
}
export function formatSelectionAverage(value: number | null | undefined, locales?: Intl.LocalesArgument): string {
return typeof value === "number" && Number.isFinite(value) ? formatSelectionAggregate(value, locales) : "—";
}