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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1511,6 +1511,7 @@ export default withEnglishFallback({
|
||||
copyColumnNamesPreview: "プレビュー",
|
||||
copyAll: "すべてコピー (TSV)",
|
||||
selectionSum: "SUM: {value}",
|
||||
selectionAverage: "AVG: {value}",
|
||||
selectionCells: "{count} 個のセル",
|
||||
clearSelection: "選択をクリア",
|
||||
export: "エクスポート",
|
||||
|
||||
@@ -1483,6 +1483,7 @@ export default withEnglishFallback({
|
||||
copyColumnNamesPreview: "미리보기",
|
||||
copyAll: "모두 복사 (TSV)",
|
||||
selectionSum: "합계: {value}",
|
||||
selectionAverage: "평균: {value}",
|
||||
selectionCells: "{count}개 셀",
|
||||
clearSelection: "선택 해제",
|
||||
export: "내보내기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1527,6 +1527,7 @@ export default withEnglishFallback({
|
||||
copyColumnNamesPreview: "预览",
|
||||
copyAll: "复制全部 (TSV)",
|
||||
selectionSum: "SUM: {value}",
|
||||
selectionAverage: "AVG: {value}",
|
||||
selectionCells: "{count} 个单元",
|
||||
clearSelection: "清除选区",
|
||||
export: "导出",
|
||||
|
||||
@@ -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) : "—";
|
||||
}
|
||||
Reference in new issue
Block a user