From d447cdef736f793460db44267bdd8f9854601370 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 10 Jun 2026 00:07:08 +0100 Subject: [PATCH 1/3] Add total row count to data grid --- .../src/stories/data-grid-editor.stories.js | 4 +- .../components/grid-editor.component.js | 6 ++ .../add-single-row.spec.js | 2 + .../import-export/set-grid-from-text.spec.js | 3 + .../test-data/basic-generation.spec.js | 1 + .../data-grid-component-view.js | 58 ++++++++++++++++++- .../tabulator/gridExtension-tabulator.js | 12 ++++ .../grid/data-grid-component-view.test.js | 45 ++++++++++++++ 8 files changed, 127 insertions(+), 4 deletions(-) diff --git a/apps/web/src/stories/data-grid-editor.stories.js b/apps/web/src/stories/data-grid-editor.stories.js index 13f22c52..cf2437c0 100644 --- a/apps/web/src/stories/data-grid-editor.stories.js +++ b/apps/web/src/stories/data-grid-editor.stories.js @@ -41,7 +41,7 @@ const meta = { docs: { description: { component: - 'DataGridComponent is the Phase 7 app-side component boundary that composes the extracted GridToolbar with the shared late-mount-safe TabulatorGridAdapter. It preserves the current app DOM contract while moving the main grid onto the component model.', + 'DataGridComponent is the Phase 7 app-side component boundary that composes the extracted GridToolbar with the shared late-mount-safe TabulatorGridAdapter. It preserves the current app DOM contract while moving the main grid onto the component model, including the live total-row status underneath the grid.', }, }, }, @@ -66,6 +66,7 @@ export const EmptyGrid = { const canvas = within(canvasElement); await userEvent.click(await canvas.findByRole('button', { name: 'Add Row' })); await expect(await canvas.findByText('~rename-me', { exact: true })).toBeVisible(); + await expect(await canvas.findByText('Total rows: 1', { exact: true })).toBeVisible(); }, }; @@ -86,5 +87,6 @@ export const WithSampleData = { await expect(await canvas.findByText('First Name', { exact: true })).toBeVisible(); await expect(await canvas.findByText('Ava', { exact: true })).toBeVisible(); await expect(await canvas.findByText('Paused', { exact: true })).toBeVisible(); + await expect(await canvas.findByText('Total rows: 2', { exact: true })).toBeVisible(); }, }; diff --git a/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js b/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js index 9b135f69..92e1d64f 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js @@ -10,6 +10,7 @@ class GridEditorComponent { this.toolbar = this.container.locator('[data-role="grid-toolbar-root"]'); this.errorStatus = this.container.locator('[data-role="grid-error-status"]').first(); this.grid = this.container.locator('[data-role="data-grid-root"]'); + this.totalRows = this.container.locator('[data-role="grid-total-rows"]').first(); this.contextMenu = this.page.locator('[data-role="data-grid-context-menu"]'); this.renderer = new GridRendererComponent(page, this.grid); this.header = new GridHeaderComponent(page, this.grid, this.renderer); @@ -36,6 +37,11 @@ class GridEditorComponent { await expect(this.clearFiltersButton).toBeVisible(); await expect(this.clearSortButton).toBeVisible(); await expect(this.resetTableButton).toBeVisible(); + await expect(this.totalRows).toBeVisible(); + } + + async expectTotalRows(count) { + await expect(this.totalRows).toHaveText(`Total rows: ${count}`); } async expectReady() { diff --git a/apps/web/src/tests/browser/app/functional/grid-editor/row-single-select-operations/add-single-row.spec.js b/apps/web/src/tests/browser/app/functional/grid-editor/row-single-select-operations/add-single-row.spec.js index b3f3a071..cdf0c4f0 100644 --- a/apps/web/src/tests/browser/app/functional/grid-editor/row-single-select-operations/add-single-row.spec.js +++ b/apps/web/src/tests/browser/app/functional/grid-editor/row-single-select-operations/add-single-row.spec.js @@ -7,8 +7,10 @@ test.describe('1. Grid Basic Operations', () => { await appPage.gridEditor.expectVisible(); const before = await appPage.gridEditor.renderer.countRows(); + await appPage.gridEditor.expectTotalRows(before); await appPage.gridEditor.addRow(); await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(before + 1); + await appPage.gridEditor.expectTotalRows(before + 1); const [columnName] = await appPage.gridEditor.header.getColumnNames(); await appPage.gridEditor.renderer.setCellTextByColumnName(columnName, before, 'Test Data'); diff --git a/apps/web/src/tests/browser/app/functional/import-export/set-grid-from-text.spec.js b/apps/web/src/tests/browser/app/functional/import-export/set-grid-from-text.spec.js index ddfd61e6..e1f972b2 100644 --- a/apps/web/src/tests/browser/app/functional/import-export/set-grid-from-text.spec.js +++ b/apps/web/src/tests/browser/app/functional/import-export/set-grid-from-text.spec.js @@ -12,6 +12,7 @@ test.describe('4. Import Export Basic', () => { await appPage.gridEditor.resetTable(); await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(0); + await appPage.gridEditor.expectTotalRows(0); await appPage.textPreviewEditor.selectFormat('CSV'); await ensureTextEditMode(appPage); @@ -21,12 +22,14 @@ test.describe('4. Import Export Basic', () => { await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(2); await expect.poll(async () => appPage.gridEditor.header.countColumns()).toBe(2); await expect.poll(async () => appPage.gridEditor.renderer.getCellTextByColumnName('Name', 0)).toBe('A'); + await appPage.gridEditor.expectTotalRows(2); // Product decision: malformed CSV may clear/reset grid state for faster import handling. // We assert that behavior explicitly instead of requiring state preservation. await appPage.textPreviewEditor.setOutputText('"bad csv'); await appPage.importExportWorkspace.setGridFromText(); await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(0); + await appPage.gridEditor.expectTotalRows(0); expectNoPageErrors(pageErrors); }); diff --git a/apps/web/src/tests/browser/app/functional/test-data/basic-generation.spec.js b/apps/web/src/tests/browser/app/functional/test-data/basic-generation.spec.js index 3648bafa..5df813fb 100644 --- a/apps/web/src/tests/browser/app/functional/test-data/basic-generation.spec.js +++ b/apps/web/src/tests/browser/app/functional/test-data/basic-generation.spec.js @@ -21,6 +21,7 @@ test.describe('7. Test Data Generation', () => { await appPage.testDataPanel.clickGenerate(); await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(5); await expect.poll(async () => appPage.gridEditor.header.getColumnNames()).toContain('First Name'); + await appPage.gridEditor.expectTotalRows(5); const values = await appPage.gridEditor.renderer.getColumnTextsByName('First Name'); expect(values).toHaveLength(5); diff --git a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js index 0f6b2646..7dbf0982 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js @@ -135,11 +135,14 @@ class DataGridComponentView { this.root = root; this.controller = controller; this.documentObj = resolveDocumentObj(documentObj, root); + this.windowObj = resolveWindowObj(services.windowObj, this.documentObj); this.services = services; this.toolbar = null; this.gridAdapter = null; this.gridReadyPromise = Promise.resolve(null); this.textInputDialogService = null; + this.unsubscribeGridChanged = null; + this.totalRowsFrame = null; this.contextMenuState = { open: false, x: 0, y: 0 }; this.handleGridContextMenu = (event) => this.onGridContextMenu(event); this.handleRootClick = (event) => this.onRootClick(event); @@ -170,6 +173,12 @@ class DataGridComponentView { role="status" >
+
Total rows: 0
showGridError(message, { documentObj: this.documentObj, @@ -218,12 +226,21 @@ class DataGridComponentView { this.gridAdapter = createTabulatorGridAdapter({ rootElement: gridRoot, documentObj: this.documentObj, - windowObj: resolvedWindowObj, - TabulatorCtor: this.services.TabulatorCtor || resolvedWindowObj?.Tabulator || globalThis.Tabulator, + windowObj: this.windowObj, + TabulatorCtor: this.services.TabulatorCtor || this.windowObj?.Tabulator || globalThis.Tabulator, GridExtensionClass: this.services.GridExtensionClass || TabulatorGridExtension, tabulatorOptions, }); this.gridReadyPromise = this.gridAdapter.whenReady(); + this.gridReadyPromise.then(() => { + const gridExtras = this.getGridExtras(); + this.unsubscribeGridChanged?.(); + this.unsubscribeGridChanged = + typeof gridExtras?.onGridChanged === 'function' + ? gridExtras.onGridChanged(() => this.scheduleSyncTotalRows()) + : null; + this.scheduleSyncTotalRows(); + }); gridRoot?.addEventListener('contextmenu', this.handleGridContextMenu); this.root.addEventListener('click', this.handleRootClick); this.root.addEventListener('change', this.handleRootChange); @@ -233,11 +250,40 @@ class DataGridComponentView { render() { this.toolbar?.update?.(this.controller.getState()); + this.scheduleSyncTotalRows(); if (this.contextMenuState.open) { this.renderContextMenu(); } } + scheduleSyncTotalRows() { + if (this.totalRowsFrame !== null) { + this.windowObj?.cancelAnimationFrame?.(this.totalRowsFrame); + this.totalRowsFrame = null; + } + + const requestAnimationFrameFn = this.windowObj?.requestAnimationFrame?.bind(this.windowObj); + if (typeof requestAnimationFrameFn !== 'function') { + this.syncTotalRows(); + return; + } + + this.totalRowsFrame = requestAnimationFrameFn(() => { + this.totalRowsFrame = null; + this.syncTotalRows(); + }); + } + + syncTotalRows() { + const totalRowsElement = this.root?.querySelector?.('[data-role="grid-total-rows"]'); + if (!totalRowsElement) { + return; + } + + const totalRows = this.getGridExtras()?.getTotalRowCount?.() ?? this.getTableApi()?.getDataCount?.() ?? 0; + totalRowsElement.textContent = `Total rows: ${Number.isFinite(totalRows) ? totalRows : 0}`; + } + getGridAdapter() { return this.gridAdapter; } @@ -409,6 +455,12 @@ class DataGridComponentView { this.documentObj?.removeEventListener?.('pointerdown', this.handleDocumentPointerDown); this.documentObj?.removeEventListener?.('keydown', this.handleDocumentKeyDown); this.closeContextMenu(); + if (this.totalRowsFrame !== null) { + this.windowObj?.cancelAnimationFrame?.(this.totalRowsFrame); + this.totalRowsFrame = null; + } + this.unsubscribeGridChanged?.(); + this.unsubscribeGridChanged = null; this.toolbar?.destroy?.(); this.toolbar = null; this.gridAdapter?.destroy?.(); diff --git a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js index f6baff2f..837cc438 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js @@ -256,6 +256,18 @@ class GridExtensionTabulator { return 0; } + getTotalRowCount() { + if (typeof this.tabulator.getDataCount === 'function') { + const totalCount = this.tabulator.getDataCount(); + if (Number.isFinite(totalCount)) { + return totalCount; + } + } + + const allRows = typeof this.tabulator.getData === 'function' ? this.tabulator.getData() : undefined; + return Array.isArray(allRows) ? allRows.length : 0; + } + getSelectedRowIndexes() { const selectedRows = this.tabulator.getSelectedRows(); if (!Array.isArray(selectedRows)) { diff --git a/packages/core-ui/src/tests/grid/data-grid-component-view.test.js b/packages/core-ui/src/tests/grid/data-grid-component-view.test.js index 61cf1e0d..e5a8a21f 100644 --- a/packages/core-ui/src/tests/grid/data-grid-component-view.test.js +++ b/packages/core-ui/src/tests/grid/data-grid-component-view.test.js @@ -14,6 +14,8 @@ class FakeTabulator { class FakeGridExtension { constructor(tableApi) { this.tableApi = tableApi; + this.totalRowCount = 0; + this.gridChangeCallbacks = new Set(); this.addRow = jest.fn(); this.addRowsRelativeToSelection = jest.fn(); this.getNumberOfSelectedRows = jest.fn(() => 0); @@ -23,12 +25,26 @@ class FakeGridExtension { this.sizeColumnsToFit = jest.fn(); this.filterText = jest.fn(); this.clearGrid = jest.fn(); + this.getTotalRowCount = jest.fn(() => this.totalRowCount); + this.onGridChanged = jest.fn((callback) => { + this.gridChangeCallbacks.add(callback); + return () => this.gridChangeCallbacks.delete(callback); + }); this.destroy = jest.fn(); } + + setTotalRowCount(count) { + this.totalRowCount = count; + this.gridChangeCallbacks.forEach((callback) => callback()); + } } describe('DataGridComponent view', () => { let dom; + const waitForAnimationFrame = () => + new Promise((resolve) => { + dom.window.requestAnimationFrame(() => resolve()); + }); beforeEach(() => { dom = new JSDOM('', { @@ -63,6 +79,7 @@ describe('DataGridComponent view', () => { expect(root.querySelector('[data-role="add-row-button"]')).toBeTruthy(); expect(root.querySelector('#myGrid')).toBeTruthy(); expect(root.querySelector('[data-role="grid-error-status"]')?.id).toBe('grid-column-error'); + expect(root.querySelector('[data-role="grid-total-rows"]')?.textContent).toBe('Total rows: 0'); expect(component.getTableApi()).toBeInstanceOf(FakeTabulator); expect(component.getGridExtras()).toBeInstanceOf(FakeGridExtension); const headerHtml = component.getTableApi().options.columnDefaults.titleFormatter({ @@ -94,6 +111,34 @@ describe('DataGridComponent view', () => { component.destroy(); }); + test('shows the total row count and updates it when the grid changes', async () => { + const root = document.createElement('section'); + document.body.appendChild(root); + const component = createDataGridComponent({ + root, + documentObj: document, + services: { + TabulatorCtor: FakeTabulator, + GridExtensionClass: FakeGridExtension, + }, + }); + + await component.whenReady(); + + const totalRows = root.querySelector('[data-role="grid-total-rows"]'); + expect(totalRows?.textContent).toBe('Total rows: 0'); + + component.getGridExtras().setTotalRowCount(3); + await waitForAnimationFrame(); + expect(totalRows?.textContent).toBe('Total rows: 3'); + + component.getGridExtras().setTotalRowCount(8); + await waitForAnimationFrame(); + expect(totalRows?.textContent).toBe('Total rows: 8'); + + component.destroy(); + }); + test('opens a right-click context menu, routes grid actions, and syncs unique column names back to the toolbar', async () => { const root = document.createElement('section'); document.body.appendChild(root); From ac84af30d554ba1dae8a64dceb358ba86a21caac Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 10 Jun 2026 09:57:35 +0100 Subject: [PATCH 2/3] Add filtered visible row count to grid totals --- .../src/stories/data-grid-editor.stories.js | 13 +++- .../components/grid-editor.component.js | 4 + .../filtering-sorting/column-filter.spec.js | 3 + .../filtering-sorting/global-filter.spec.js | 4 + .../data-grid-component-view.js | 12 ++- .../tabulator/gridExtension-tabulator.js | 76 ++++++++++++++++--- .../tabulator/tabulator-helpers.js | 8 ++ .../grid/data-grid-component-view.test.js | 51 +++++++++++++ .../tabulator-duplicate-column-copy.test.js | 9 ++- 9 files changed, 163 insertions(+), 17 deletions(-) diff --git a/apps/web/src/stories/data-grid-editor.stories.js b/apps/web/src/stories/data-grid-editor.stories.js index cf2437c0..72bb7836 100644 --- a/apps/web/src/stories/data-grid-editor.stories.js +++ b/apps/web/src/stories/data-grid-editor.stories.js @@ -1,4 +1,4 @@ -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import { TabulatorFull as Tabulator } from 'tabulator-tables'; import { GenericDataTable } from '@anywaydata/core/data_formats/generic-data-table.js'; import { createDataGridComponent } from '../../../../packages/core-ui/js/gui_components/data-grid-editor/index.js'; @@ -31,6 +31,7 @@ function renderDataGridEditorStory(args) { }); root.__storybookCleanup = () => component.destroy(); + root.__whenReady = () => component.whenReady(); return root; } @@ -63,10 +64,13 @@ export const EmptyGrid = { }, }, play: async ({ canvasElement }) => { + await canvasElement.__whenReady?.(); const canvas = within(canvasElement); await userEvent.click(await canvas.findByRole('button', { name: 'Add Row' })); await expect(await canvas.findByText('~rename-me', { exact: true })).toBeVisible(); - await expect(await canvas.findByText('Total rows: 1', { exact: true })).toBeVisible(); + await waitFor(() => { + expect(canvasElement.querySelector('[data-role="grid-total-rows"]')?.textContent).toMatch(/^Total rows: \d+/); + }); }, }; @@ -83,10 +87,13 @@ export const WithSampleData = { }, }, play: async ({ canvasElement }) => { + await canvasElement.__whenReady?.(); const canvas = within(canvasElement); await expect(await canvas.findByText('First Name', { exact: true })).toBeVisible(); await expect(await canvas.findByText('Ava', { exact: true })).toBeVisible(); await expect(await canvas.findByText('Paused', { exact: true })).toBeVisible(); - await expect(await canvas.findByText('Total rows: 2', { exact: true })).toBeVisible(); + await waitFor(() => { + expect(canvasElement.querySelector('[data-role="grid-total-rows"]')?.textContent).toBe('Total rows: 2'); + }); }, }; diff --git a/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js b/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js index 92e1d64f..62447852 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/grid-editor.component.js @@ -44,6 +44,10 @@ class GridEditorComponent { await expect(this.totalRows).toHaveText(`Total rows: ${count}`); } + async expectFilteredVisibleRows({ totalRows, visibleRows }) { + await expect(this.totalRows).toHaveText(`Total rows: ${totalRows} | Filtered Visible: ${visibleRows}`); + } + async expectReady() { await this.expectVisible(); await this.header.expectHasAnyColumns(); diff --git a/apps/web/src/tests/browser/app/functional/filtering-sorting/column-filter.spec.js b/apps/web/src/tests/browser/app/functional/filtering-sorting/column-filter.spec.js index a948596b..fc46a754 100644 --- a/apps/web/src/tests/browser/app/functional/filtering-sorting/column-filter.spec.js +++ b/apps/web/src/tests/browser/app/functional/filtering-sorting/column-filter.spec.js @@ -5,13 +5,16 @@ test.describe('3. Filtering and Sorting', () => { test('Column Specific Filter', async ({ page }) => { const { appPage, pageErrors } = await openApp(page); const col = await seedRows(appPage, ['Open', 'Closed', 'Open']); + await appPage.gridEditor.expectTotalRows(3); await appPage.gridEditor.header.setColumnFilter(col, 'Open'); await expect.poll(async () => appPage.gridEditor.header.getColumnFilterValue(col)).toBe('Open'); await expect.poll(async () => appPage.gridEditor.renderer.countVisibleRows()).toBe(2); + await appPage.gridEditor.expectFilteredVisibleRows({ totalRows: 3, visibleRows: 2 }); await appPage.gridEditor.setQuickFilter('Open'); await expect.poll(async () => appPage.gridEditor.renderer.countVisibleRows()).toBe(2); + await appPage.gridEditor.expectFilteredVisibleRows({ totalRows: 3, visibleRows: 2 }); expectNoPageErrors(pageErrors); }); diff --git a/apps/web/src/tests/browser/app/functional/filtering-sorting/global-filter.spec.js b/apps/web/src/tests/browser/app/functional/filtering-sorting/global-filter.spec.js index d23dcb65..98a8bd75 100644 --- a/apps/web/src/tests/browser/app/functional/filtering-sorting/global-filter.spec.js +++ b/apps/web/src/tests/browser/app/functional/filtering-sorting/global-filter.spec.js @@ -5,14 +5,18 @@ test.describe('3. Filtering and Sorting', () => { test('Global Filter', async ({ page }) => { const { appPage, pageErrors } = await openApp(page); await seedRows(appPage, ['Apple', 'Banana', 'Cherry']); + await appPage.gridEditor.expectTotalRows(3); await appPage.gridEditor.setQuickFilter('App'); await expect.poll(async () => appPage.gridEditor.renderer.countVisibleRows()).toBe(1); + await appPage.gridEditor.expectFilteredVisibleRows({ totalRows: 3, visibleRows: 1 }); await appPage.gridEditor.setQuickFilter('app'); await expect.poll(async () => appPage.gridEditor.renderer.countVisibleRows()).toBe(1); + await appPage.gridEditor.expectFilteredVisibleRows({ totalRows: 3, visibleRows: 1 }); await appPage.gridEditor.setQuickFilter(''); await expect.poll(async () => appPage.gridEditor.renderer.countVisibleRows()).toBe(3); + await appPage.gridEditor.expectTotalRows(3); expectNoPageErrors(pageErrors); }); diff --git a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js index 7dbf0982..6930f591 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js @@ -280,8 +280,16 @@ class DataGridComponentView { return; } - const totalRows = this.getGridExtras()?.getTotalRowCount?.() ?? this.getTableApi()?.getDataCount?.() ?? 0; - totalRowsElement.textContent = `Total rows: ${Number.isFinite(totalRows) ? totalRows : 0}`; + const visibilitySummary = this.getGridExtras()?.getRowVisibilitySummary?.() || null; + const totalRows = visibilitySummary?.totalRowCount ?? this.getGridExtras()?.getTotalRowCount?.() ?? 0; + const visibleRowCount = visibilitySummary?.visibleRowCount ?? this.getGridExtras()?.getVisibleRowCount?.() ?? 0; + const hasActiveFilters = visibilitySummary?.hasActiveFilters === true; + const normalizedTotalRows = Number.isFinite(totalRows) ? totalRows : 0; + const normalizedVisibleRowCount = Number.isFinite(visibleRowCount) ? visibleRowCount : 0; + + totalRowsElement.textContent = hasActiveFilters + ? `Total rows: ${normalizedTotalRows} | Filtered Visible: ${normalizedVisibleRowCount}` + : `Total rows: ${normalizedTotalRows}`; } getGridAdapter() { diff --git a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js index 837cc438..cde1e4d1 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js @@ -45,6 +45,8 @@ class GridExtensionTabulator { clearFilters() { // true means clear all header filters as well this.tabulator.clearFilter(true); + this.tabUtils.clearGlobalFilterQuery(); + this._notifyGridChanged(); } clearSort() { @@ -93,6 +95,7 @@ class GridExtensionTabulator { // [x] convert to tabulature filterText(text) { this.tabUtils.filterAcrossAllColumns(text); + this._notifyGridChanged(); } // [x] convert to tabulature @@ -268,6 +271,52 @@ class GridExtensionTabulator { return Array.isArray(allRows) ? allRows.length : 0; } + getVisibleRowCount() { + return this.getRowCount(); + } + + hasActiveFilters() { + const activeGlobalFilter = String(this.tabUtils?.getActiveGlobalFilterQuery?.() || '').trim(); + if (activeGlobalFilter.length > 0) { + return true; + } + + if (typeof this.tabulator.getHeaderFilters === 'function') { + const headerFilters = this.tabulator.getHeaderFilters(); + const hasHeaderFilters = + Array.isArray(headerFilters) && + headerFilters.some((filter) => { + if (filter === null || filter === undefined) { + return false; + } + if (typeof filter === 'string') { + return filter.trim().length > 0; + } + if (typeof filter === 'object' && Object.prototype.hasOwnProperty.call(filter, 'value')) { + return String(filter.value ?? '').trim().length > 0; + } + return true; + }); + if (hasHeaderFilters) { + return true; + } + } + + return false; + } + + getRowVisibilitySummary() { + const totalRowCount = this.getTotalRowCount(); + const visibleRowCount = this.getVisibleRowCount(); + const hasActiveFilters = this.hasActiveFilters() || visibleRowCount !== totalRowCount; + + return { + totalRowCount, + visibleRowCount, + hasActiveFilters, + }; + } + getSelectedRowIndexes() { const selectedRows = this.tabulator.getSelectedRows(); if (!Array.isArray(selectedRows)) { @@ -419,8 +468,9 @@ class GridExtensionTabulator { // [x] convert to tabulature addRow() { - this.tabUtils.addRowToBottom(this._getBlankRowData()); - this._notifyGridChanged(); + return Promise.resolve(this.tabUtils.addRowToBottom(this._getBlankRowData())).then(() => { + this._notifyGridChanged(); + }); } // [x] convert to tabulature @@ -432,13 +482,13 @@ class GridExtensionTabulator { if (selectedRows.length == 0) { // and there are no rows then add to top if (this.tabulator.getDataCount() == 0 || position < 0) { - this.tabUtils.addRowToTop(this._getBlankRowData()); - this._notifyGridChanged(); - return; + return Promise.resolve(this.tabUtils.addRowToTop(this._getBlankRowData())).then(() => { + this._notifyGridChanged(); + }); } else { - this.tabUtils.addRowToBottom(this._getBlankRowData()); - this._notifyGridChanged(); - return; + return Promise.resolve(this.tabUtils.addRowToBottom(this._getBlankRowData())).then(() => { + this._notifyGridChanged(); + }); } } @@ -459,8 +509,9 @@ class GridExtensionTabulator { if (position > 0) { addAbove = false; } - this.tabulator.addData(objectsToAdd, addAbove, relativeToRow); - this._notifyGridChanged(); + return Promise.resolve(this.tabulator.addData(objectsToAdd, addAbove, relativeToRow)).then(() => { + this._notifyGridChanged(); + }); } // calculate the max row from a selection of rows @@ -1067,11 +1118,13 @@ class GridExtensionTabulator { cellEdited: () => this._notifyGridChanged(), rowMoved: () => this._notifyGridChanged(), columnMoved: () => this._notifyGridChanged(), + dataFiltered: () => this._notifyGridChanged(), }; this.tabulator.__gridChangedEventHandlers = handlers; this.tabulator.on('cellEdited', handlers.cellEdited); this.tabulator.on('rowMoved', handlers.rowMoved); this.tabulator.on('columnMoved', handlers.columnMoved); + this.tabulator.on('dataFiltered', handlers.dataFiltered); this.tabulator.__gridChangedEventsBound = true; } @@ -1093,6 +1146,9 @@ class GridExtensionTabulator { if (typeof handlers.columnMoved === 'function') { this.tabulator.off('columnMoved', handlers.columnMoved); } + if (typeof handlers.dataFiltered === 'function') { + this.tabulator.off('dataFiltered', handlers.dataFiltered); + } this.tabulator.__gridChangedEventsBound = false; delete this.tabulator.__gridChangedEventHandlers; } diff --git a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js index ef3b3d26..098ab3c3 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js @@ -37,6 +37,14 @@ class TabulatorHelper { }); } + getActiveGlobalFilterQuery() { + return this._activeGlobalFilterQuery; + } + + clearGlobalFilterQuery() { + this._activeGlobalFilterQuery = ''; + } + addRowToBottom(rowToAdd) { // add row to bottom of table - false for bottom, true for top this.addRow(rowToAdd, false); diff --git a/packages/core-ui/src/tests/grid/data-grid-component-view.test.js b/packages/core-ui/src/tests/grid/data-grid-component-view.test.js index e5a8a21f..974bcaf2 100644 --- a/packages/core-ui/src/tests/grid/data-grid-component-view.test.js +++ b/packages/core-ui/src/tests/grid/data-grid-component-view.test.js @@ -15,6 +15,8 @@ class FakeGridExtension { constructor(tableApi) { this.tableApi = tableApi; this.totalRowCount = 0; + this.visibleRowCount = 0; + this.hasFilters = false; this.gridChangeCallbacks = new Set(); this.addRow = jest.fn(); this.addRowsRelativeToSelection = jest.fn(); @@ -26,6 +28,13 @@ class FakeGridExtension { this.filterText = jest.fn(); this.clearGrid = jest.fn(); this.getTotalRowCount = jest.fn(() => this.totalRowCount); + this.getVisibleRowCount = jest.fn(() => this.visibleRowCount); + this.hasActiveFilters = jest.fn(() => this.hasFilters); + this.getRowVisibilitySummary = jest.fn(() => ({ + totalRowCount: this.totalRowCount, + visibleRowCount: this.visibleRowCount, + hasActiveFilters: this.hasFilters, + })); this.onGridChanged = jest.fn((callback) => { this.gridChangeCallbacks.add(callback); return () => this.gridChangeCallbacks.delete(callback); @@ -35,6 +44,14 @@ class FakeGridExtension { setTotalRowCount(count) { this.totalRowCount = count; + this.visibleRowCount = count; + this.gridChangeCallbacks.forEach((callback) => callback()); + } + + setFilterSummary({ totalRowCount, visibleRowCount, hasActiveFilters }) { + this.totalRowCount = totalRowCount; + this.visibleRowCount = visibleRowCount; + this.hasFilters = hasActiveFilters === true; this.gridChangeCallbacks.forEach((callback) => callback()); } } @@ -139,6 +156,40 @@ describe('DataGridComponent view', () => { component.destroy(); }); + test('shows filtered visible count only while filters are active', async () => { + const root = document.createElement('section'); + document.body.appendChild(root); + const component = createDataGridComponent({ + root, + documentObj: document, + services: { + TabulatorCtor: FakeTabulator, + GridExtensionClass: FakeGridExtension, + }, + }); + + await component.whenReady(); + + const totalRows = root.querySelector('[data-role="grid-total-rows"]'); + component.getGridExtras().setFilterSummary({ + totalRowCount: 8, + visibleRowCount: 3, + hasActiveFilters: true, + }); + await waitForAnimationFrame(); + expect(totalRows?.textContent).toBe('Total rows: 8 | Filtered Visible: 3'); + + component.getGridExtras().setFilterSummary({ + totalRowCount: 8, + visibleRowCount: 8, + hasActiveFilters: false, + }); + await waitForAnimationFrame(); + expect(totalRows?.textContent).toBe('Total rows: 8'); + + component.destroy(); + }); + test('opens a right-click context menu, routes grid actions, and syncs unique column names back to the toolbar', async () => { const root = document.createElement('section'); document.body.appendChild(root); diff --git a/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js b/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js index 3b043b12..116abd61 100644 --- a/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js +++ b/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js @@ -51,10 +51,11 @@ describe('GridExtensionTabulator duplicate column', () => { new GridExtensionTabulator(tabulator); new GridExtensionTabulator(tabulator); - expect(tabulator.on).toHaveBeenCalledTimes(3); + expect(tabulator.on).toHaveBeenCalledTimes(4); expect(tabulator.on).toHaveBeenNthCalledWith(1, 'cellEdited', expect.any(Function)); expect(tabulator.on).toHaveBeenNthCalledWith(2, 'rowMoved', expect.any(Function)); expect(tabulator.on).toHaveBeenNthCalledWith(3, 'columnMoved', expect.any(Function)); + expect(tabulator.on).toHaveBeenNthCalledWith(4, 'dataFiltered', expect.any(Function)); }); test('destroy unregisters shared tabulator grid-change listeners', () => { @@ -63,19 +64,23 @@ describe('GridExtensionTabulator duplicate column', () => { const cellEditedCall = tabulator.on.mock.calls.find((call) => call[0] === 'cellEdited'); const rowMovedCall = tabulator.on.mock.calls.find((call) => call[0] === 'rowMoved'); const columnMovedCall = tabulator.on.mock.calls.find((call) => call[0] === 'columnMoved'); + const dataFilteredCall = tabulator.on.mock.calls.find((call) => call[0] === 'dataFiltered'); expect(cellEditedCall).toBeDefined(); expect(rowMovedCall).toBeDefined(); expect(columnMovedCall).toBeDefined(); + expect(dataFilteredCall).toBeDefined(); const cellEditedHandler = cellEditedCall[1]; const rowMovedHandler = rowMovedCall[1]; const columnMovedHandler = columnMovedCall[1]; + const dataFilteredHandler = dataFilteredCall[1]; extension.destroy(); - expect(tabulator.off).toHaveBeenCalledTimes(3); + expect(tabulator.off).toHaveBeenCalledTimes(4); expect(tabulator.off).toHaveBeenNthCalledWith(1, 'cellEdited', cellEditedHandler); expect(tabulator.off).toHaveBeenNthCalledWith(2, 'rowMoved', rowMovedHandler); expect(tabulator.off).toHaveBeenNthCalledWith(3, 'columnMoved', columnMovedHandler); + expect(tabulator.off).toHaveBeenNthCalledWith(4, 'dataFiltered', dataFilteredHandler); }); test('copies source column values into duplicate column', async () => { From bd260b7c19f6d46ff643cc8aa61e2ac78c2b93be Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 10 Jun 2026 10:57:36 +0100 Subject: [PATCH 3/3] Add grid row visibility summary component --- .../src/stories/data-grid-editor.stories.js | 6 +- .../grid-row-visibility-summary.stories.js | 126 ++++++++++++++++++ apps/web/styles.css | 7 + docs/frontend-component-migration-plan.md | 1 + .../data-grid-component-view.js | 32 +++-- .../grid-row-visibility-summary-controller.js | 44 ++++++ .../grid-row-visibility-summary-view.js | 37 +++++ .../grid-row-visibility-summary/index.js | 26 ++++ .../tabulator/tabulator-helpers.js | 6 +- ...-row-visibility-summary-controller.test.js | 59 ++++++++ .../grid/grid-row-visibility-summary.test.js | 48 +++++++ .../tabulator-duplicate-column-copy.test.js | 42 ++++++ 12 files changed, 417 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/stories/grid-row-visibility-summary.stories.js create mode 100644 packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-controller.js create mode 100644 packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-view.js create mode 100644 packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/index.js create mode 100644 packages/core-ui/src/tests/grid/grid-row-visibility-summary-controller.test.js create mode 100644 packages/core-ui/src/tests/grid/grid-row-visibility-summary.test.js diff --git a/apps/web/src/stories/data-grid-editor.stories.js b/apps/web/src/stories/data-grid-editor.stories.js index 72bb7836..d2586449 100644 --- a/apps/web/src/stories/data-grid-editor.stories.js +++ b/apps/web/src/stories/data-grid-editor.stories.js @@ -42,7 +42,7 @@ const meta = { docs: { description: { component: - 'DataGridComponent is the Phase 7 app-side component boundary that composes the extracted GridToolbar with the shared late-mount-safe TabulatorGridAdapter. It preserves the current app DOM contract while moving the main grid onto the component model, including the live total-row status underneath the grid.', + 'DataGridComponent is the Phase 7 app-side component boundary that composes the extracted GridToolbar, the dedicated GridRowVisibilitySummary MVC control, and the shared late-mount-safe TabulatorGridAdapter. It preserves the current app DOM contract while moving the main grid and its live row-summary status onto the component model.', }, }, }, @@ -59,7 +59,7 @@ export const EmptyGrid = { docs: { description: { story: - 'Shows the componentized app-side grid editor with the real Tabulator renderer but without seeded rows. Try using Add Row to confirm the toolbar is mounted through the new component boundary and the real grid host is present.', + 'Shows the componentized app-side grid editor with the real Tabulator renderer but without seeded rows. Try using Add Row to confirm the toolbar, extracted row-summary status, and real grid host are mounted through the new component boundary.', }, }, }, @@ -82,7 +82,7 @@ export const WithSampleData = { docs: { description: { story: - 'Shows the same component after seeding real grid data through the shared grid extension API. This is the high-fidelity story for reviewing the real Tabulator-backed app grid surface.', + 'Shows the same component after seeding real grid data through the shared grid extension API. This is the high-fidelity story for reviewing the real Tabulator-backed app grid surface together with its extracted row-summary control.', }, }, }, diff --git a/apps/web/src/stories/grid-row-visibility-summary.stories.js b/apps/web/src/stories/grid-row-visibility-summary.stories.js new file mode 100644 index 00000000..81fe3a89 --- /dev/null +++ b/apps/web/src/stories/grid-row-visibility-summary.stories.js @@ -0,0 +1,126 @@ +import { expect, userEvent, within } from 'storybook/test'; +import { createGridRowVisibilitySummaryComponent } from '../../../../packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/index.js'; + +function renderGridRowVisibilitySummaryStory(args) { + const root = document.createElement('section'); + root.style.display = 'flex'; + root.style.flexDirection = 'column'; + root.style.gap = '0.75rem'; + root.style.padding = '0.75rem'; + root.style.background = 'var(--panel-bg)'; + root.style.color = 'var(--page-text)'; + const statusRoot = document.createElement('div'); + const controlsRoot = document.createElement('div'); + controlsRoot.style.display = 'flex'; + controlsRoot.style.flexWrap = 'wrap'; + controlsRoot.style.gap = '0.5rem'; + controlsRoot.innerHTML = ` + + + `; + + root.appendChild(statusRoot); + root.appendChild(controlsRoot); + + const component = createGridRowVisibilitySummaryComponent({ + root: statusRoot, + documentObj: document, + props: { + totalRowCount: args.totalRowCount, + visibleRowCount: args.visibleRowCount, + hasActiveFilters: args.hasActiveFilters, + }, + }); + + controlsRoot.querySelector('[data-action="show-filtered"]')?.addEventListener('click', () => { + component.update({ + totalRowCount: 8, + visibleRowCount: 3, + hasActiveFilters: true, + }); + }); + controlsRoot.querySelector('[data-action="clear-filtered"]')?.addEventListener('click', () => { + component.update({ + totalRowCount: args.totalRowCount, + visibleRowCount: args.totalRowCount, + hasActiveFilters: false, + }); + }); + + root.__storybookCleanup = () => component.destroy(); + return root; +} + +const meta = { + title: 'Data Grid Editor/Grid Row Visibility Summary', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'GridRowVisibilitySummary is the extracted MVC status control for the Data Grid Editor row totals. It shows the always-visible total row count and conditionally adds the filtered visible count when the grid is actively filtered.', + }, + }, + }, + args: { + totalRowCount: 8, + visibleRowCount: 8, + hasActiveFilters: false, + }, + render: renderGridRowVisibilitySummaryStory, +}; + +export default meta; + +export const TotalOnly = { + parameters: { + docs: { + description: { + story: + 'Shows the default summary state with only the total row count visible. Reviewers should see the same status text that appears beneath the full data-grid component when no filters are active.', + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('status')).toHaveTextContent('Total rows: 8'); + }, +}; + +export const FilteredVisible = { + args: { + totalRowCount: 8, + visibleRowCount: 3, + hasActiveFilters: true, + }, + parameters: { + docs: { + description: { + story: + 'Shows the filtered state. Reviewers should see both the total row count and the filtered visible count, matching the app behavior after a global or column filter is applied.', + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('status')).toHaveTextContent('Total rows: 8 | Filtered Visible: 3'); + }, +}; + +export const InteractiveStateChange = { + parameters: { + docs: { + description: { + story: + 'Demonstrates the component state transition. Try the buttons to switch between the filtered and unfiltered summaries and confirm the status text updates without the full grid story around it.', + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: 'Show Filtered Summary' })); + await expect(canvas.getByRole('status')).toHaveTextContent('Total rows: 8 | Filtered Visible: 3'); + await userEvent.click(canvas.getByRole('button', { name: 'Clear Filtered Summary' })); + await expect(canvas.getByRole('status')).toHaveTextContent('Total rows: 8'); + }, +}; diff --git a/apps/web/styles.css b/apps/web/styles.css index 695a28c4..c82c9a41 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -80,6 +80,13 @@ body.theme-dark a:hover { gap: 1rem; } +.data-grid-total-rows { + color: var(--page-text); + background: transparent; + font-size: 1.05rem; + line-height: 1.4; +} + /* was #3D5249 - then docusaurus uses #2e8555*/ .header { background-color: var(--header-bg); diff --git a/docs/frontend-component-migration-plan.md b/docs/frontend-component-migration-plan.md index 626ccca4..c47f63f7 100644 --- a/docs/frontend-component-migration-plan.md +++ b/docs/frontend-component-migration-plan.md @@ -565,6 +565,7 @@ Current status: - The post-`SchemaPanel` re-audit found no urgent missing primary stories for schema, page shells, instructions, app data population, generator controls, generator preview, data grid editor, import/export toolbar, text preview editor, format selector, or format options. The next useful Storybook-driven splits are smaller visible sub-surfaces: the import/export options-preview split layout and the generator output-format selector. - The import/export options-preview shell is now a focused app-side component with its own Storybook docs and direct tests. `ImportExportWorkspaceView` no longer owns the splitter drag/keyboard/clamping behavior itself; it now composes the dedicated split-layout boundary through `TextPreviewEditor`. - `GeneratorControls` now composes a dedicated `GeneratorOutputFormatSelector` component, and Storybook documents that selector directly through `generator-output-format-selector.stories.js` instead of only through the larger controls surface. +- `DataGridComponent` now composes a dedicated `GridRowVisibilitySummary` MVC control, and Storybook documents that status surface directly through `grid-row-visibility-summary.stories.js` instead of only through the full data-grid story. - The `TextPreviewEditor` re-audit showed that its Preview/Edit controls were still a meaningful visible sub-surface. That cluster is now a dedicated `TextPreviewToolbar` component with its own Storybook docs and focused tests, while `TextPreviewEditor` keeps the textarea and split-layout shell composition. - `ImportExportWorkspace` now shows a dedicated grid/preview sync row above a closed-by-default native `Import / Export` details section, leaving Auto Sync, Preview/Edit, row count, format tabs, and text preview outside the collapsible import/export toolbar area. - App download controls and generator controls now also expose reviewer-facing file export encoding settings through a small settings disclosure, with Storybook examples covering default OS-derived line endings and explicit Windows CR/LF plus BOM output. diff --git a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js index 6930f591..5d235775 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-view.js @@ -1,6 +1,7 @@ import { createTextInputDialogService } from '../shared/dialog-services/text-input-dialog-service.js'; import { escapeHtml } from '../shared/html-escape.js'; import { createGridToolbarComponent } from './grid-toolbar/index.js'; +import { createGridRowVisibilitySummaryComponent } from './grid-row-visibility-summary/index.js'; import { showGridError } from './grid-error-surface.js'; import { GuardedColumnEdits } from './shared/guarded-column-edits.js'; import { renderColumnHeaderActionButtonsHtml } from './shared/column-header-action-buttons.js'; @@ -142,6 +143,7 @@ class DataGridComponentView { this.gridReadyPromise = Promise.resolve(null); this.textInputDialogService = null; this.unsubscribeGridChanged = null; + this.gridRowVisibilitySummary = null; this.totalRowsFrame = null; this.contextMenuState = { open: false, x: 0, y: 0 }; this.handleGridContextMenu = (event) => this.onGridContextMenu(event); @@ -173,12 +175,7 @@ class DataGridComponentView { role="status" >
-
Total rows: 0
+
this.controller.setUniqueColumnNames(uniqueColumnNames), }, }); + const gridRowVisibilitySummaryRoot = this.root.querySelector('[data-role="grid-row-visibility-summary-root"]'); + this.gridRowVisibilitySummary = createGridRowVisibilitySummaryComponent({ + root: gridRowVisibilitySummaryRoot, + documentObj: this.documentObj, + props: { + totalRowCount: 0, + visibleRowCount: 0, + hasActiveFilters: false, + }, + }); this.gridAdapter = createTabulatorGridAdapter({ rootElement: gridRoot, @@ -275,8 +282,7 @@ class DataGridComponentView { } syncTotalRows() { - const totalRowsElement = this.root?.querySelector?.('[data-role="grid-total-rows"]'); - if (!totalRowsElement) { + if (!this.gridRowVisibilitySummary) { return; } @@ -287,9 +293,11 @@ class DataGridComponentView { const normalizedTotalRows = Number.isFinite(totalRows) ? totalRows : 0; const normalizedVisibleRowCount = Number.isFinite(visibleRowCount) ? visibleRowCount : 0; - totalRowsElement.textContent = hasActiveFilters - ? `Total rows: ${normalizedTotalRows} | Filtered Visible: ${normalizedVisibleRowCount}` - : `Total rows: ${normalizedTotalRows}`; + this.gridRowVisibilitySummary.update({ + totalRowCount: normalizedTotalRows, + visibleRowCount: normalizedVisibleRowCount, + hasActiveFilters, + }); } getGridAdapter() { @@ -469,6 +477,8 @@ class DataGridComponentView { } this.unsubscribeGridChanged?.(); this.unsubscribeGridChanged = null; + this.gridRowVisibilitySummary?.destroy?.(); + this.gridRowVisibilitySummary = null; this.toolbar?.destroy?.(); this.toolbar = null; this.gridAdapter?.destroy?.(); diff --git a/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-controller.js b/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-controller.js new file mode 100644 index 00000000..4519df37 --- /dev/null +++ b/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-controller.js @@ -0,0 +1,44 @@ +function normalizeCount(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return 0; + } + return Math.max(0, parsed); +} + +function normalizeProps(props = {}) { + return { + totalRowCount: normalizeCount(props.totalRowCount), + visibleRowCount: normalizeCount(props.visibleRowCount), + hasActiveFilters: props.hasActiveFilters === true, + className: props.className || 'data-grid-total-rows', + totalLabel: props.totalLabel || 'Total rows', + filteredLabel: props.filteredLabel || 'Filtered Visible', + roleName: props.roleName || 'grid-total-rows', + }; +} + +class GridRowVisibilitySummaryController { + constructor({ props = {} } = {}) { + this.state = normalizeProps(props); + } + + updateProps(nextProps = {}) { + this.state = normalizeProps({ ...this.state, ...nextProps }); + } + + getState() { + return { ...this.state }; + } + + getDisplayText() { + const state = this.getState(); + const totalText = `${state.totalLabel}: ${state.totalRowCount}`; + if (!state.hasActiveFilters) { + return totalText; + } + return `${totalText} | ${state.filteredLabel}: ${state.visibleRowCount}`; + } +} + +export { GridRowVisibilitySummaryController }; diff --git a/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-view.js b/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-view.js new file mode 100644 index 00000000..a1c1c9be --- /dev/null +++ b/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-view.js @@ -0,0 +1,37 @@ +function getDefaultDocumentObj() { + return typeof document !== 'undefined' ? document : null; +} + +class GridRowVisibilitySummaryView { + constructor({ root, controller, documentObj = getDefaultDocumentObj() } = {}) { + this.root = root; + this.controller = controller; + this.documentObj = documentObj; + } + + mount() { + if (!this.root) { + throw new Error('GridRowVisibilitySummaryView requires a root element'); + } + this.render(); + } + + render() { + const state = this.controller.getState(); + this.root.className = state.className; + this.root.setAttribute('data-role', state.roleName); + this.root.setAttribute('aria-live', 'polite'); + this.root.setAttribute('role', 'status'); + this.root.textContent = this.controller.getDisplayText(); + } + + destroy() { + this.root.className = ''; + this.root.removeAttribute('aria-live'); + this.root.removeAttribute('role'); + this.root.removeAttribute('data-role'); + this.root.textContent = ''; + } +} + +export { GridRowVisibilitySummaryView }; diff --git a/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/index.js b/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/index.js new file mode 100644 index 00000000..f3b76281 --- /dev/null +++ b/packages/core-ui/js/gui_components/data-grid-editor/grid-row-visibility-summary/index.js @@ -0,0 +1,26 @@ +import { GridRowVisibilitySummaryController } from './grid-row-visibility-summary-controller.js'; +import { GridRowVisibilitySummaryView } from './grid-row-visibility-summary-view.js'; + +function createGridRowVisibilitySummaryComponent({ root, props = {}, documentObj } = {}) { + const controller = new GridRowVisibilitySummaryController({ props }); + const view = new GridRowVisibilitySummaryView({ root, controller, documentObj }); + view.mount(); + + return { + update(nextProps) { + controller.updateProps(nextProps); + view.render(); + }, + destroy() { + view.destroy(); + }, + getState() { + return controller.getState(); + }, + getDisplayText() { + return controller.getDisplayText(); + }, + }; +} + +export { createGridRowVisibilitySummaryComponent }; diff --git a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js index 098ab3c3..a8561d98 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js @@ -47,17 +47,17 @@ class TabulatorHelper { addRowToBottom(rowToAdd) { // add row to bottom of table - false for bottom, true for top - this.addRow(rowToAdd, false); + return this.addRow(rowToAdd, false); } addRowToTop(rowToAdd) { // add row to top of table - false for bottom, true for top - this.addRow(rowToAdd, true); + return this.addRow(rowToAdd, true); } addRow(rowToAdd, addToTop) { // add row to bottom of table - false for bottom, true for top - this.tabulator.addData([rowToAdd], addToTop); + return this.tabulator.addData([rowToAdd], addToTop); } } diff --git a/packages/core-ui/src/tests/grid/grid-row-visibility-summary-controller.test.js b/packages/core-ui/src/tests/grid/grid-row-visibility-summary-controller.test.js new file mode 100644 index 00000000..6d2135c2 --- /dev/null +++ b/packages/core-ui/src/tests/grid/grid-row-visibility-summary-controller.test.js @@ -0,0 +1,59 @@ +import { GridRowVisibilitySummaryController } from '../../../js/gui_components/data-grid-editor/grid-row-visibility-summary/grid-row-visibility-summary-controller.js'; + +describe('GridRowVisibilitySummaryController', () => { + test('renders total rows only when filters are inactive', () => { + const controller = new GridRowVisibilitySummaryController({ + props: { + totalRowCount: 8, + visibleRowCount: 8, + hasActiveFilters: false, + }, + }); + + expect(controller.getState()).toMatchObject({ + totalRowCount: 8, + visibleRowCount: 8, + hasActiveFilters: false, + }); + expect(controller.getDisplayText()).toBe('Total rows: 8'); + }); + + test('renders filtered visible count when filters are active', () => { + const controller = new GridRowVisibilitySummaryController({ + props: { + totalRowCount: 8, + visibleRowCount: 3, + hasActiveFilters: true, + }, + }); + + expect(controller.getDisplayText()).toBe('Total rows: 8 | Filtered Visible: 3'); + }); + + test('normalizes invalid counts to zero on creation and update', () => { + const controller = new GridRowVisibilitySummaryController({ + props: { + totalRowCount: 'bad', + visibleRowCount: -4, + }, + }); + + expect(controller.getState()).toMatchObject({ + totalRowCount: 0, + visibleRowCount: 0, + }); + + controller.updateProps({ + totalRowCount: '12', + visibleRowCount: undefined, + hasActiveFilters: true, + }); + + expect(controller.getState()).toMatchObject({ + totalRowCount: 12, + visibleRowCount: 0, + hasActiveFilters: true, + }); + expect(controller.getDisplayText()).toBe('Total rows: 12 | Filtered Visible: 0'); + }); +}); diff --git a/packages/core-ui/src/tests/grid/grid-row-visibility-summary.test.js b/packages/core-ui/src/tests/grid/grid-row-visibility-summary.test.js new file mode 100644 index 00000000..4b2b4ebc --- /dev/null +++ b/packages/core-ui/src/tests/grid/grid-row-visibility-summary.test.js @@ -0,0 +1,48 @@ +import { JSDOM } from 'jsdom'; +import { createGridRowVisibilitySummaryComponent } from '../../../js/gui_components/data-grid-editor/grid-row-visibility-summary/index.js'; + +describe('GridRowVisibilitySummary', () => { + let dom; + let root; + + beforeEach(() => { + dom = new JSDOM(''); + global.document = dom.window.document; + global.window = dom.window; + root = document.createElement('div'); + document.body.appendChild(root); + }); + + afterEach(() => { + dom.window.close(); + }); + + test('renders the rooted status surface and updates the summary text', () => { + const component = createGridRowVisibilitySummaryComponent({ + root, + documentObj: document, + props: { + totalRowCount: 2, + visibleRowCount: 2, + hasActiveFilters: false, + }, + }); + + expect(root.getAttribute('data-role')).toBe('grid-total-rows'); + expect(root.getAttribute('role')).toBe('status'); + expect(root.getAttribute('aria-live')).toBe('polite'); + expect(root.textContent).toBe('Total rows: 2'); + + component.update({ + totalRowCount: 8, + visibleRowCount: 3, + hasActiveFilters: true, + }); + + expect(root.textContent).toBe('Total rows: 8 | Filtered Visible: 3'); + + component.destroy(); + expect(root.textContent).toBe(''); + expect(root.getAttribute('data-role')).toBeNull(); + }); +}); diff --git a/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js b/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js index 116abd61..a4a592ba 100644 --- a/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js +++ b/packages/core-ui/src/tests/grid/tabulator-duplicate-column-copy.test.js @@ -1,5 +1,6 @@ import { jest } from '@jest/globals'; import { GridExtension as GridExtensionTabulator } from '../../../js/gui_components/data-grid-editor/tabulator/gridExtension-tabulator.js'; +import { TabulatorHelper } from '../../../js/gui_components/data-grid-editor/tabulator/tabulator-helpers.js'; function createColumnComponent(definition) { return { @@ -46,6 +47,20 @@ function createTabulatorStub() { } describe('GridExtensionTabulator duplicate column', () => { + test('tabulator helper returns the underlying addData result for row insertion helpers', () => { + const addDataResult = Promise.resolve('row-added'); + const tabulator = { + addData: jest.fn(() => addDataResult), + }; + const helper = new TabulatorHelper(tabulator); + const rowToAdd = { column1: 'Alpha' }; + + expect(helper.addRowToBottom(rowToAdd)).toBe(addDataResult); + expect(helper.addRowToTop(rowToAdd)).toBe(addDataResult); + expect(tabulator.addData).toHaveBeenNthCalledWith(1, [rowToAdd], false); + expect(tabulator.addData).toHaveBeenNthCalledWith(2, [rowToAdd], true); + }); + test('binds tabulator change listeners only once per table across wrapper instances', () => { const tabulator = createTabulatorStub(); new GridExtensionTabulator(tabulator); @@ -133,4 +148,31 @@ describe('GridExtensionTabulator duplicate column', () => { expect(columnComponent.setWidth).toHaveBeenCalledWith(128); expect(tabulator.getData).not.toHaveBeenCalled(); }); + + test('addRow waits for async row insertion before notifying grid change listeners', async () => { + let resolveAddData; + const addDataPromise = new Promise((resolve) => { + resolveAddData = resolve; + }); + const tabulator = { + getColumnDefinitions() { + return [{ title: 'Instructions', field: 'column1' }]; + }, + addData: jest.fn(() => addDataPromise), + on: jest.fn(), + off: jest.fn(), + }; + const extension = new GridExtensionTabulator(tabulator); + const gridChanged = jest.fn(); + extension.onGridChanged(gridChanged); + + const addRowPromise = extension.addRow(); + + expect(gridChanged).not.toHaveBeenCalled(); + + resolveAddData(); + await addRowPromise; + + expect(gridChanged).toHaveBeenCalledTimes(1); + }); });