diff --git a/apps/api/jest.config.cjs b/apps/api/jest.config.cjs index e9633085..ae810376 100644 --- a/apps/api/jest.config.cjs +++ b/apps/api/jest.config.cjs @@ -1,5 +1,8 @@ +const baseConfig = require('../../jest.config.cjs'); + module.exports = { - testEnvironment: "node", - testRegex: "src[/\\\\].*\\.test\\.js$", - transform: {}, + ...baseConfig, + testEnvironment: 'node', + rootDir: '../..', + testMatch: ['**/apps/api/src/*.test.js'], }; diff --git a/apps/cli/jest.config.cjs b/apps/cli/jest.config.cjs index d988b7fc..684bb915 100644 --- a/apps/cli/jest.config.cjs +++ b/apps/cli/jest.config.cjs @@ -1,8 +1,8 @@ +const baseConfig = require('../../jest.config.cjs'); + module.exports = { - verbose: true, + ...baseConfig, testEnvironment: 'node', - transform: { - '^.+\\.js$': 'babel-jest', - }, - testRegex: 'src[/\\\\]tests[/\\\\].*\\.test\\.js$', + rootDir: '../..', + testMatch: ['**/apps/cli/src/tests/**/*.test.js'], }; diff --git a/apps/mcp/jest.config.cjs b/apps/mcp/jest.config.cjs index 273f7408..aea932ce 100644 --- a/apps/mcp/jest.config.cjs +++ b/apps/mcp/jest.config.cjs @@ -1,5 +1,8 @@ +const baseConfig = require('../../jest.config.cjs'); + module.exports = { + ...baseConfig, testEnvironment: 'node', - transform: {}, - testRegex: 'src[/\\\\].*\\.test\\.js$', + rootDir: '../..', + testMatch: ['**/apps/mcp/src/*.test.js'], }; 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 1444b8e9..9b135f69 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.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); this.confirmDialog = new ConfirmDialogComponent(page); @@ -176,6 +177,24 @@ class GridEditorComponent { await this._acceptConfirmIfVisible(); } + async openContextMenuOnCell(columnName, rowIndex) { + const columnIndex = await this.renderer._columnIndexByName(columnName); + await this.renderer.row(rowIndex).locator('.tabulator-cell').nth(columnIndex).click({ button: 'right' }); + await expect(this.contextMenu).toBeVisible(); + } + + async contextMenuAddRow() { + await this.contextMenu.getByRole('button', { name: 'Add Row', exact: true }).click(); + } + + async contextMenuSetUniqueColumnNames(enabled = true) { + const checkbox = this.contextMenu.getByRole('checkbox', { name: 'Enforce Unique Column Names' }); + const isChecked = await checkbox.isChecked(); + if (isChecked !== enabled) { + await checkbox.click(); + } + } + async setUniqueColumnNames(enabled = true) { if (enabled) { await this.uniqueColumnNamesCheckbox.check(); diff --git a/apps/web/src/tests/browser/app/functional/grid-editor/context-menu/right-click-context-menu.spec.js b/apps/web/src/tests/browser/app/functional/grid-editor/context-menu/right-click-context-menu.spec.js new file mode 100644 index 00000000..43b7c690 --- /dev/null +++ b/apps/web/src/tests/browser/app/functional/grid-editor/context-menu/right-click-context-menu.spec.js @@ -0,0 +1,23 @@ +const { test } = require('@playwright/test'); +const { openApp, seedRows, expectNoPageErrors, expect } = require('../../../abstractions/helpers/scenario-helpers'); + +test.describe('1. Grid Basic Operations', () => { + test('Right click context menu can add rows and sync unique column names to the toolbar', async ({ page }) => { + const { appPage, pageErrors } = await openApp(page); + await seedRows(appPage, ['Alpha', 'Beta']); + + const [columnName] = await appPage.gridEditor.header.getColumnNames(); + const initialRowCount = await appPage.gridEditor.renderer.countRows(); + + await appPage.gridEditor.openContextMenuOnCell(columnName, 0); + await appPage.gridEditor.contextMenuAddRow(); + await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(initialRowCount + 1); + + await appPage.gridEditor.openContextMenuOnCell(columnName, 0); + await appPage.gridEditor.contextMenuSetUniqueColumnNames(true); + await expect(appPage.gridEditor.uniqueColumnNamesCheckbox).toBeChecked(); + await expect(appPage.gridEditor.contextMenu).toBeHidden(); + + expectNoPageErrors(pageErrors); + }); +}); diff --git a/apps/web/src/tests/jest/script-bootstrap.test.js b/apps/web/src/tests/jest/script-bootstrap.test.js index 0989652b..13a2e586 100644 --- a/apps/web/src/tests/jest/script-bootstrap.test.js +++ b/apps/web/src/tests/jest/script-bootstrap.test.js @@ -79,10 +79,13 @@ describe('script bootstrap', () => { expect(createDataGridComponentFn).toHaveBeenCalledWith({ root: dom.window.document.getElementById('main-grid-view'), documentObj: dom.window.document, - services: { + services: expect.objectContaining({ TabulatorCtor: globalThis.Tabulator, GridExtensionClass: expect.any(Function), - }, + getPreviewExportFormats: expect.any(Function), + previewAs: expect.any(Function), + exportAs: expect.any(Function), + }), }); expect(dom.window.document.getElementById('initial-load')).toBeNull(); }); diff --git a/apps/web/src/tests/jest/script-module-init.test.js b/apps/web/src/tests/jest/script-module-init.test.js index 2b8f2e0f..1e6deb8e 100644 --- a/apps/web/src/tests/jest/script-module-init.test.js +++ b/apps/web/src/tests/jest/script-module-init.test.js @@ -117,10 +117,13 @@ describe('script module initialization', () => { expect(createDataGridComponent).toHaveBeenCalledWith({ root: global.document.getElementById('main-grid-view'), documentObj: global.document, - services: { + services: expect.objectContaining({ TabulatorCtor: globalThis.Tabulator, GridExtensionClass: expect.any(Function), - }, + getPreviewExportFormats: expect.any(Function), + previewAs: expect.any(Function), + exportAs: expect.any(Function), + }), }); expect(createImportExportWorkspaceComponent).toHaveBeenCalledWith({ root: global.document.getElementById('import-export-controls'), diff --git a/apps/web/styles.css b/apps/web/styles.css index ea3e974d..8dab698a 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -696,6 +696,61 @@ Custom header styles for default items height: 0.8rem; } +.data-grid-context-menu { + position: fixed; + z-index: 2500; + min-width: 14rem; + max-width: min(22rem, calc(100vw - 1rem)); + padding: 0.45rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--panel-bg); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); +} + +.data-grid-context-menu__section, +.data-grid-context-menu__submenu { + display: grid; + gap: 0.25rem; +} + +.data-grid-context-menu__section > button, +.data-grid-context-menu__submenu > button { + width: 100%; + text-align: left; +} + +.data-grid-context-menu__separator { + height: 1px; + margin: 0.45rem 0; + background: color-mix(in srgb, var(--border-color) 75%, transparent); +} + +.data-grid-context-menu__checkbox { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.95rem; +} + +.data-grid-context-menu__details { + margin: 0; +} + +.data-grid-context-menu__details > summary { + cursor: pointer; + font-weight: 600; +} + +.data-grid-context-menu__details[open] { + border: 0; + background: transparent; +} + +.data-grid-context-menu__details[open] > summary { + margin-bottom: 0.35rem; +} + /* Docs */ .bodycontent { diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 00000000..f514d19b --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,21 @@ +module.exports = { + verbose: true, + transform: {}, + testMatch: [ + '**/packages/core/src/tests/**/*.test.js', + '**/packages/core-ui/src/tests/**/*.test.js', + '**/tests/integration/**/*.test.js', + '**/apps/web/src/tests/jest/**/*.test.js', + ], + testPathIgnorePatterns: ['/node_modules/', '/tests/browser/', '/apps/web/src/tests/browser/'], + modulePathIgnorePatterns: ['/build/'], + collectCoverageFrom: ['packages/**/*.js', '!packages/core/js/libs/**'], + moduleNameMapper: { + '^@anywaydata/core$': '/packages/core/src/index.js', + '^@anywaydata/core/(.*)$': '/packages/core/js/$1', + '^@anywaydata/core/data_formats/(.*)$': '/packages/core/js/data_formats/$1', + 'https://cdn.skypack.dev/@faker-js/faker@v9.7.0': '@faker-js/faker', + 'https://unpkg.com/papaparse@5.5.2/papaparse.min.js': 'papaparse', + '/libs/randexp.min.js': 'randexp', + }, +}; diff --git a/package.json b/package.json index ab85a7fb..fe5bdabe 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ "apps/*" ], "scripts": { - "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js", - "testverbose": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --verbose", - "testcoverage": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --coverage", + "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./jest.config.cjs", + "testverbose": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./jest.config.cjs --verbose", + "testcoverage": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./jest.config.cjs --coverage", "storybook": "storybook dev -p 6006", "build-storybook": "node ./scripts/run-build-storybook.mjs", "test:storybook": "node ./scripts/run-storybook-tests.mjs", - "test:ui:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js packages/core-ui/src/tests apps/web/src/tests/jest", + "test:ui:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./jest.config.cjs --testPathPatterns packages/core-ui/src/tests apps/web/src/tests/jest", "testenv:create": "node ./scripts/create-testenv.mjs", "generate:faker-help": "node ./scripts/generate-faker-help.js", "test:browser": "node ./node_modules/@playwright/test/cli.js test", @@ -38,7 +38,7 @@ "build:web": "vite build --config ./apps/web/vite.config.mjs", "preview:web": "vite preview --config ./apps/web/vite.config.mjs", "serve:web": "pnpm run dev:web", - "benchmark:tabulator": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js packages/core-ui/src/tests/grid/tabulator-performance.benchmark.test.js --runInBand", + "benchmark:tabulator": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./jest.config.cjs --runTestsByPath packages/core-ui/src/tests/grid/tabulator-performance.benchmark.test.js --runInBand", "anywaydata:mac": "pnpm --dir docs-src run anywaydata && pnpm run build:web && node ./scripts/merge-web-build-into-docs-build.js && cp -R ./build/ ../../anywaydata-com", "anywaydata:win": "pnpm --dir docs-src run anywaydata && pnpm run build:web && node .\\scripts\\merge-web-build-into-docs-build.js && echo cleaning anywaydata for deployment && ..\\anywaydata-com\\make-ready-for-prod.bat", "previewdocs": "pnpm --dir docs-src run start", diff --git a/packages/core-ui/js/gui_components/app/import-export-workspace/create-import-export-workspace-runtime.js b/packages/core-ui/js/gui_components/app/import-export-workspace/create-import-export-workspace-runtime.js index 29071a88..b1d48dd4 100644 --- a/packages/core-ui/js/gui_components/app/import-export-workspace/create-import-export-workspace-runtime.js +++ b/packages/core-ui/js/gui_components/app/import-export-workspace/create-import-export-workspace-runtime.js @@ -150,6 +150,24 @@ function createImportExportWorkspaceRuntime({ root, props = {}, services = {}, d setFileFormatType(format) { workflow.handleFormatChange(format ?? getType()); }, + openDetails() { + view.openToolbarDetails?.(); + render(); + }, + previewAs(format) { + const selectedFormat = format ?? getType(); + if (controller.getState().mode !== 'preview') { + controller.updateProps({ mode: 'preview', previewTextDirty: false }); + render(); + } + workflow.handleFormatChange(selectedFormat); + return workflow.renderTextFromGrid(); + }, + async downloadAs(format) { + const selectedFormat = format ?? getType(); + workflow.handleFormatChange(selectedFormat); + return workflow.fileDownload(); + }, setOptionsViewForFormatType: workflow.setOptionsViewForFormatType, setCurrentTypeOptions: workflow.setCurrentTypeOptions, applyCurrentTypeOptions(options) { diff --git a/packages/core-ui/js/gui_components/app/import-export-workspace/import-export-workspace-view.js b/packages/core-ui/js/gui_components/app/import-export-workspace/import-export-workspace-view.js index cdfe1d24..51332425 100644 --- a/packages/core-ui/js/gui_components/app/import-export-workspace/import-export-workspace-view.js +++ b/packages/core-ui/js/gui_components/app/import-export-workspace/import-export-workspace-view.js @@ -140,6 +140,17 @@ class ImportExportWorkspaceView { return this.textPreviewEditor?.getTextArea?.() || null; } + getToolbarDetailsElement() { + return this.getElementByRole('import-export-toolbar-details'); + } + + openToolbarDetails() { + const detailsElement = this.getToolbarDetailsElement(); + if (detailsElement) { + detailsElement.open = true; + } + } + setCopyButtonText(value) { this.textPreviewEditor?.setCopyButtonText?.(value); } diff --git a/packages/core-ui/js/gui_components/app/page/app-page-runtime.js b/packages/core-ui/js/gui_components/app/page/app-page-runtime.js index e7ed9622..403ecac1 100644 --- a/packages/core-ui/js/gui_components/app/page/app-page-runtime.js +++ b/packages/core-ui/js/gui_components/app/page/app-page-runtime.js @@ -6,6 +6,7 @@ import { createDataGridComponent } from '../../data-grid-editor/index.js'; import { ensureGridLibraryLoaded } from '../../data-grid-editor/grid-library-loader.js'; import { GridExtension as TabulatorGridExtension } from '../../data-grid-editor/tabulator/gridExtension-tabulator.js'; import { createImportExportWorkspaceComponent } from '../import-export-workspace/index.js'; +import { createDefaultTabDefinitions } from '../format-selector/format-selector-controller.js'; import { createInstructionsComponent } from '../../shared/instructions/index.js'; import { APP_PAGE_INSTRUCTIONS_PROPS } from '../../shared/instructions/app-page-instructions.js'; import { createAppPageComponent } from './app-page-shell.js'; @@ -117,6 +118,18 @@ function createInstructionsGridActions({ documentObj = getDefaultDocumentObj(), }; } +function createContextMenuFormatItems() { + return createDefaultTabDefinitions().flatMap((definition) => { + if (Array.isArray(definition.subtasks)) { + return definition.subtasks.map((subtask) => ({ + type: subtask.selectedType || subtask.type, + label: subtask.label, + })); + } + return definition.type ? [{ type: definition.type, label: definition.label }] : []; + }); +} + async function bootstrapApp({ documentObj = getDefaultDocumentObj(), ensureGridLibraryLoadedFn = ensureGridLibraryLoaded, @@ -168,6 +181,7 @@ async function bootstrapApp({ let exporter = null; let importer = null; let instructionsGridActions = null; + const contextMenuFormatItems = createContextMenuFormatItems(); try { mainDataGrid = createDataGridComponentFn({ root: documentObj.getElementById('main-grid-view'), @@ -175,6 +189,15 @@ async function bootstrapApp({ services: { TabulatorCtor: resolvedTabulatorCtor, GridExtensionClass, + getPreviewExportFormats: () => contextMenuFormatItems, + previewAs: async (format) => { + importExportController?.openDetails?.(); + return importExportController?.previewAs?.(format); + }, + exportAs: async (format) => { + importExportController?.openDetails?.(); + return importExportController?.downloadAs?.(format); + }, }, }); diff --git a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-controller.js b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-controller.js index 98fb486d..85b7f856 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-controller.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/data-grid-component-controller.js @@ -67,6 +67,10 @@ class DataGridComponentController { this.getGridExtras()?.clearSort?.(); } + sizeColumnsToFit() { + this.getGridExtras()?.sizeColumnsToFit?.(); + } + setFilterText(filterText) { this.state.filterText = filterText || ''; this.getGridExtras()?.filterText?.(this.state.filterText); 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 8c21f6e6..0f6b2646 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 @@ -139,6 +139,13 @@ class DataGridComponentView { this.toolbar = null; this.gridAdapter = null; this.gridReadyPromise = Promise.resolve(null); + this.textInputDialogService = null; + this.contextMenuState = { open: false, x: 0, y: 0 }; + this.handleGridContextMenu = (event) => this.onGridContextMenu(event); + this.handleRootClick = (event) => this.onRootClick(event); + this.handleRootChange = (event) => this.onRootChange(event); + this.handleDocumentPointerDown = (event) => this.onDocumentPointerDown(event); + this.handleDocumentKeyDown = (event) => this.onDocumentKeyDown(event); } mount() { @@ -163,6 +170,11 @@ class DataGridComponentView { role="status" >
+ `; } @@ -171,7 +183,7 @@ class DataGridComponentView { const toolbarRoot = this.root.querySelector('[data-role="grid-toolbar-root"]'); const gridRoot = this.root.querySelector('[data-role="data-grid-root"]'); const createGridToolbar = this.services.createGridToolbar || createGridToolbarComponent; - const textInputDialogService = createTextInputDialogService({ documentObj: this.documentObj }); + this.textInputDialogService = createTextInputDialogService({ documentObj: this.documentObj }); const resolvedWindowObj = resolveWindowObj(this.services.windowObj, this.documentObj); const showScopedGridError = (message) => showGridError(message, { @@ -181,7 +193,7 @@ class DataGridComponentView { const tabulatorOptions = this.services.tabulatorOptions || createAppGridTabulatorOptions({ - textInputDialogService, + textInputDialogService: this.textInputDialogService, surfaceError: showScopedGridError, shouldEnforceUniqueNames: () => this.controller.getState().uniqueColumnNames === true, }); @@ -212,10 +224,18 @@ class DataGridComponentView { tabulatorOptions, }); this.gridReadyPromise = this.gridAdapter.whenReady(); + gridRoot?.addEventListener('contextmenu', this.handleGridContextMenu); + this.root.addEventListener('click', this.handleRootClick); + this.root.addEventListener('change', this.handleRootChange); + this.documentObj?.addEventListener?.('pointerdown', this.handleDocumentPointerDown); + this.documentObj?.addEventListener?.('keydown', this.handleDocumentKeyDown); } render() { this.toolbar?.update?.(this.controller.getState()); + if (this.contextMenuState.open) { + this.renderContextMenu(); + } } getGridAdapter() { @@ -234,7 +254,161 @@ class DataGridComponentView { return this.gridReadyPromise; } + renderContextMenu() { + const menuRoot = this.root.querySelector('[data-role="data-grid-context-menu"]'); + if (!menuRoot) { + return; + } + + const state = this.controller.getState(); + menuRoot.hidden = false; + menuRoot.style.left = `${this.contextMenuState.x}px`; + menuRoot.style.top = `${this.contextMenuState.y}px`; + menuRoot.innerHTML = ` +
+ + + + +
+ + + +
+ + + + + +
+ `; + } + + openContextMenu(x, y) { + this.contextMenuState = { open: true, x, y }; + this.renderContextMenu(); + } + + closeContextMenu() { + this.contextMenuState = { ...this.contextMenuState, open: false }; + const menuRoot = this.root.querySelector('[data-role="data-grid-context-menu"]'); + if (!menuRoot) { + return; + } + menuRoot.hidden = true; + menuRoot.replaceChildren(); + } + + onGridContextMenu(event) { + event.preventDefault(); + this.syncSelectionToContextTarget(event.target); + this.openContextMenu(event.clientX, event.clientY); + } + + syncSelectionToContextTarget(target) { + const rowElement = target?.closest?.('.tabulator-row'); + if (!rowElement) { + return; + } + + const tableApi = this.getTableApi(); + const rowComponents = typeof tableApi?.getRows === 'function' ? tableApi.getRows() : []; + const targetRow = rowComponents.find((rowComponent) => rowComponent?.getElement?.() === rowElement); + if (!targetRow || rowElement.classList.contains('tabulator-selected')) { + return; + } + + if (typeof tableApi?.deselectRow === 'function') { + tableApi.deselectRow(); + } + targetRow.select?.(); + } + + async onRootClick(event) { + const actionElement = event.target?.closest?.('[data-context-action]'); + if (!actionElement) { + return; + } + + const action = actionElement.getAttribute('data-context-action'); + if (!action || action === 'unique-column-names') { + return; + } + + if (action === 'add-row') { + this.controller.addRow(); + } else if (action === 'add-rows-above') { + this.controller.addRows(-1); + } else if (action === 'add-rows-below') { + this.controller.addRows(1); + } else if (action === 'delete-selected-rows') { + await this.controller.deleteSelectedRows(); + } else if (action === 'edit-filter') { + const nextFilter = await this.textInputDialogService?.requestTextInput?.({ + title: 'Filter Grid', + initialValue: this.controller.getState().filterText || '', + }); + if (nextFilter !== null) { + this.controller.setFilterText(nextFilter); + this.render(); + } + } else if (action === 'clear-filters') { + this.controller.clearFilters(); + this.render(); + } else if (action === 'clear-sort') { + this.controller.clearSort(); + } else if (action === 'reset-table') { + await this.controller.clearTable(); + } else if (action === 'auto-fit-columns') { + this.controller.sizeColumnsToFit(); + } + + this.closeContextMenu(); + } + + onRootChange(event) { + const checkbox = event.target?.closest?.('[data-context-action="unique-column-names"]'); + if (!checkbox) { + return; + } + this.controller.setUniqueColumnNames(checkbox.checked === true); + this.toolbar?.update?.(this.controller.getState()); + this.closeContextMenu(); + } + + onDocumentPointerDown(event) { + if (!this.contextMenuState.open) { + return; + } + const menuRoot = this.root.querySelector('[data-role="data-grid-context-menu"]'); + if (menuRoot?.contains?.(event.target)) { + return; + } + this.closeContextMenu(); + } + + onDocumentKeyDown(event) { + if (event.key === 'Escape' && this.contextMenuState.open) { + this.closeContextMenu(); + } + } + destroy() { + this.root + .querySelector('[data-role="data-grid-root"]') + ?.removeEventListener('contextmenu', this.handleGridContextMenu); + this.root.removeEventListener('click', this.handleRootClick); + this.root.removeEventListener('change', this.handleRootChange); + this.documentObj?.removeEventListener?.('pointerdown', this.handleDocumentPointerDown); + this.documentObj?.removeEventListener?.('keydown', this.handleDocumentKeyDown); + this.closeContextMenu(); this.toolbar?.destroy?.(); this.toolbar = null; this.gridAdapter?.destroy?.(); diff --git a/packages/core-ui/js/gui_components/data-grid-editor/index.js b/packages/core-ui/js/gui_components/data-grid-editor/index.js index 9120d8fa..98fcfe50 100644 --- a/packages/core-ui/js/gui_components/data-grid-editor/index.js +++ b/packages/core-ui/js/gui_components/data-grid-editor/index.js @@ -42,7 +42,9 @@ function createDataGridComponent({ root, props = {}, services = {}, documentObj whenReady() { return view.whenGridReady(); }, - sizeColumnsToFit() {}, + sizeColumnsToFit() { + controller.sizeColumnsToFit(); + }, getState() { return controller.getState(); }, 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 f257ad7f..f6baff2f 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 @@ -1,5 +1,7 @@ import { GenericDataTable } from '@anywaydata/core/data_formats/generic-data-table.js'; import { TabulatorHelper } from './tabulator-helpers.js'; + +const AUTO_FIT_ROW_SAMPLE_LIMIT = 500; /* A wrapper for Tabulature to conform to the main abstraction that makes it easier to add new columns @@ -51,6 +53,43 @@ class GridExtensionTabulator { } } + sizeColumnsToFit() { + const columnDefinitions = this.tabulator.getColumnDefinitions?.() || []; + if (columnDefinitions.length === 0) { + return; + } + + const columnsByField = new Map(); + if (typeof this.tabulator.getColumns === 'function') { + this.tabulator.getColumns().forEach((column) => { + const definition = column?.getDefinition?.() || {}; + if (definition.field) { + columnsByField.set(definition.field, column); + } + }); + } + + this._runWithoutRedraw(() => { + columnDefinitions.forEach((definition) => { + const field = definition?.field; + if (!field) { + return; + } + + const width = this._estimateColumnWidth(field); + const column = columnsByField.get(field); + if (typeof column?.setWidth === 'function') { + column.setWidth(width); + return; + } + + if (typeof column?.updateDefinition === 'function') { + column.updateDefinition({ width }); + } + }); + }); + } + // [x] convert to tabulature filterText(text) { this.tabUtils.filterAcrossAllColumns(text); @@ -686,16 +725,19 @@ class GridExtensionTabulator { } _estimateFirstColumnWidth(firstColumnField) { + return this._estimateColumnWidth(firstColumnField); + } + + _estimateColumnWidth(fieldName) { const columnDefinitions = this.tabulator.getColumnDefinitions?.() || []; - const firstDefinition = - columnDefinitions.find((definition) => definition.field === firstColumnField) || columnDefinitions[0] || {}; - const titleLength = String(firstDefinition.title || '').length; + const columnDefinition = columnDefinitions.find((definition) => definition.field === fieldName) || {}; + const titleLength = String(columnDefinition.title || '').length; - const activeRows = typeof this.tabulator.getData === 'function' ? this.tabulator.getData('active') : []; + const activeRows = this._getLimitedActiveRowData(AUTO_FIT_ROW_SAMPLE_LIMIT); let maxTextLength = titleLength; if (Array.isArray(activeRows)) { for (let rowIndex = 0; rowIndex < activeRows.length; rowIndex++) { - const value = activeRows[rowIndex]?.[firstColumnField]; + const value = activeRows[rowIndex]?.[fieldName]; const valueLength = String(value ?? '').length; if (valueLength > maxTextLength) { maxTextLength = valueLength; diff --git a/packages/core-ui/src/tests/grid/data-grid-component-controller.test.js b/packages/core-ui/src/tests/grid/data-grid-component-controller.test.js index 72da5ece..0afde25d 100644 --- a/packages/core-ui/src/tests/grid/data-grid-component-controller.test.js +++ b/packages/core-ui/src/tests/grid/data-grid-component-controller.test.js @@ -17,6 +17,7 @@ describe('DataGridComponentController', () => { deleteSelectedRows: jest.fn(), clearFilters: jest.fn(), clearSort: jest.fn(), + sizeColumnsToFit: jest.fn(), filterText: jest.fn(), clearGrid: jest.fn(), }; @@ -34,6 +35,7 @@ describe('DataGridComponentController', () => { controller.setFilterText('alpha'); controller.clearFilters(); controller.clearSort(); + controller.sizeColumnsToFit(); await controller.deleteSelectedRows(); await controller.clearTable(); @@ -43,6 +45,7 @@ describe('DataGridComponentController', () => { expect(gridExtras.filterText).toHaveBeenCalledWith('alpha'); expect(gridExtras.clearFilters).toHaveBeenCalledTimes(1); expect(gridExtras.clearSort).toHaveBeenCalledTimes(1); + expect(gridExtras.sizeColumnsToFit).toHaveBeenCalledTimes(1); expect(gridExtras.deleteSelectedRows).toHaveBeenCalledTimes(1); expect(gridExtras.clearGrid).toHaveBeenCalledTimes(1); expect(controller.getState().filterText).toBe(''); 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 ec6cd0f6..61cf1e0d 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 @@ -7,6 +7,7 @@ class FakeTabulator { this.rootElement = rootElement; this.options = options; this.destroy = jest.fn(); + this.getRows = jest.fn(() => []); } } @@ -19,6 +20,7 @@ class FakeGridExtension { this.deleteSelectedRows = jest.fn(); this.clearFilters = jest.fn(); this.clearSort = jest.fn(); + this.sizeColumnsToFit = jest.fn(); this.filterText = jest.fn(); this.clearGrid = jest.fn(); this.destroy = jest.fn(); @@ -92,6 +94,58 @@ describe('DataGridComponent view', () => { 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); + const component = createDataGridComponent({ + root, + documentObj: document, + services: { + TabulatorCtor: FakeTabulator, + GridExtensionClass: FakeGridExtension, + }, + }); + + await component.whenReady(); + + const gridRoot = root.querySelector('[data-role="data-grid-root"]'); + gridRoot.dispatchEvent( + new dom.window.MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 32, + clientY: 48, + }) + ); + + const menuRoot = root.querySelector('[data-role="data-grid-context-menu"]'); + expect(menuRoot.hidden).toBe(false); + expect(menuRoot.getAttribute('role')).toBeNull(); + expect(menuRoot.getAttribute('aria-label')).toBeNull(); + + menuRoot.querySelector('[data-context-action="auto-fit-columns"]').click(); + expect(component.getGridExtras().sizeColumnsToFit).toHaveBeenCalledTimes(1); + + expect(menuRoot.textContent).not.toContain('Preview As'); + expect(menuRoot.textContent).not.toContain('Export As'); + + gridRoot.dispatchEvent( + new dom.window.MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 32, + clientY: 48, + }) + ); + const uniqueColumnNamesCheckbox = menuRoot.querySelector('[data-context-action="unique-column-names"]'); + uniqueColumnNamesCheckbox.checked = true; + uniqueColumnNamesCheckbox.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + expect(component.getState().uniqueColumnNames).toBe(true); + expect(root.querySelector('[data-role="unique-column-names-checkbox"]')?.checked).toBe(true); + + component.destroy(); + }); + test('prefers the root owner window Tabulator when no service override is supplied', async () => { const originalTabulator = globalThis.Tabulator; const root = document.createElement('section'); 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 6b716269..3b043b12 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 @@ -4,6 +4,7 @@ import { GridExtension as GridExtensionTabulator } from '../../../js/gui_compone function createColumnComponent(definition) { return { getDefinition: () => definition, + setWidth: jest.fn(), }; } @@ -90,4 +91,41 @@ describe('GridExtensionTabulator duplicate column', () => { expect(tabulator._rowData[0][duplicatedField]).toBe(tabulator._rowData[0].column1); expect(tabulator._rowData[1][duplicatedField]).toBe(tabulator._rowData[1].column1); }); + + test('sizeColumnsToFit samples active rows instead of scanning the full data set', () => { + const columnDefinitions = [{ title: 'Instructions', field: 'column1' }]; + const sampledRows = Array.from({ length: 500 }, (_, index) => ({ + column1: index === 499 ? '1234567890' : 'short', + })); + const ignoredLongRow = { column1: 'x'.repeat(200) }; + const rowData = [...sampledRows, ignoredLongRow]; + const columnComponent = createColumnComponent(columnDefinitions[0]); + const tabulator = { + _rowData: rowData, + getColumnDefinitions() { + return columnDefinitions; + }, + getColumns() { + return [columnComponent]; + }, + getRows(mode) { + if (mode !== 'active') { + return []; + } + return this._rowData.map((row) => ({ + getData: () => row, + })); + }, + getData: jest.fn(), + on: jest.fn(), + off: jest.fn(), + redraw() {}, + }; + const extension = new GridExtensionTabulator(tabulator); + + extension.sizeColumnsToFit(); + + expect(columnComponent.setWidth).toHaveBeenCalledWith(128); + expect(tabulator.getData).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/package.json b/packages/core/package.json index 3d5a58dd..7f54ef94 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ }, "scripts": { "build": "node -e \"console.log('core: no build step')\"", - "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --config ../../package.json --testMatch \"/packages/core/src/tests/**/*.test.js\"" + "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --config ../../jest.config.cjs --testPathPatterns packages/core/src/tests" }, "dependencies": { "@faker-js/faker": "9.7.0",