From 23e1f02396edfc8b13062fe00fe512bddf63ca1b Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 10 Jun 2026 14:08:52 +0100 Subject: [PATCH 1/2] Add grid to enum schema workflow --- .../src/stories/population-actions.stories.js | 18 +- .../test-data-population-toolbar.stories.js | 14 + .../components/confirm-dialog.component.js | 8 +- .../components/test-data-panel.component.js | 14 + .../components/text-input-dialog.component.js | 6 +- .../test-data/grid-to-enum-schema.spec.js | 58 ++++ .../040-test-data/015-data-grid-editable.md | 36 ++- .../data-population-panel-controller.js | 2 + .../data-population-panel-view.js | 7 + .../app/data-population-panel/index.js | 7 + .../app/population-actions/index.js | 4 + .../population-actions-controller.js | 13 + .../population-actions-view.js | 19 ++ .../controller/test-data-grid-controller.js | 117 +++++++- .../schema/grid-to-enum-schema.js | 78 +++++ .../app/test-data-population-toolbar/index.js | 4 + ...test-data-population-toolbar-controller.js | 2 + .../test-data-population-toolbar-view.js | 16 + .../gui_components/shared/modal-text-input.js | 39 ++- .../shared/schema-definition/index.js | 3 + .../shared-schema-definition-controller.js | 4 + .../schema/shared-schema-editor-controller.js | 11 + .../tests/app/data-population-panel.test.js | 16 +- .../src/tests/app/population-actions.test.js | 15 +- .../app/test-data-population-toolbar.test.js | 8 + .../test-data-grid-controller.test.js | 277 ++++++++++++++++++ .../grid/schema/grid-to-enum-schema.test.js | 77 +++++ .../src/tests/shared/dialog-services.test.js | 18 +- .../shared-schema-definition-view.test.js | 24 ++ .../src/tests/utils/modal-text-input.test.js | 31 ++ 30 files changed, 927 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/tests/browser/app/functional/test-data/grid-to-enum-schema.spec.js create mode 100644 packages/core-ui/js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js create mode 100644 packages/core-ui/src/tests/grid/schema/grid-to-enum-schema.test.js diff --git a/apps/web/src/stories/population-actions.stories.js b/apps/web/src/stories/population-actions.stories.js index 0b86966f..a6b1d7d4 100644 --- a/apps/web/src/stories/population-actions.stories.js +++ b/apps/web/src/stories/population-actions.stories.js @@ -26,8 +26,10 @@ function renderPopulationActionsStory(args) { generatePairwiseBusy: args.generatePairwiseBusy, generateLabel: args.generateLabel, generatePairwiseLabel: args.generatePairwiseLabel, + generateSchemaLabel: 'Grid to Enum Schema', generateHelpHtml: args.generateHelpHtml, generatePairwiseHelpHtml: args.generatePairwiseHelpHtml, + generateSchemaHelpHtml: '

Scan the current grid and build an enum-only schema from visible values.

', }, callbacks: { onGenerate: () => { @@ -38,6 +40,10 @@ function renderPopulationActionsStory(args) { args.onGeneratePairwise?.(); result.textContent = 'action:generate-secondary'; }, + onGenerateSchemaFromGrid: () => { + args.onGenerateSchemaFromGrid?.(); + result.textContent = 'action:generate-schema'; + }, }, }); @@ -79,6 +85,7 @@ const meta = { '

Generate n-wise combinations from enum columns in the current schema directly into the grid.

N-wise generation docs

', onGenerate: fn(), onGeneratePairwise: fn(), + onGenerateSchemaFromGrid: fn(), }, argTypes: { pairwiseVisible: { @@ -117,6 +124,10 @@ const meta = { description: 'Storybook action fired when the secondary action button is clicked.', table: { category: 'Events' }, }, + onGenerateSchemaFromGrid: { + description: 'Storybook action fired when the Grid to Enum Schema button is clicked.', + table: { category: 'Events' }, + }, }, render: renderPopulationActionsStory, }; @@ -137,7 +148,8 @@ export const Default = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate', exact: true })).toBeEnabled(); await expect(canvas.queryByRole('button', { name: 'Generate Combinations' })).toBeNull(); - await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(1); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeVisible(); + await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(2); await userEvent.click(canvas.getByRole('button', { name: 'Generate', exact: true })); await expect(canvas.getByText('action:generate')).toBeVisible(); }, @@ -160,7 +172,8 @@ export const PairwiseAvailable = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeVisible(); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeEnabled(); - await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(2); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeVisible(); + await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(3); await userEvent.click(canvas.getByRole('button', { name: 'Generate Combinations' })); await expect(canvas.getByText('action:generate-secondary')).toBeVisible(); }, @@ -185,6 +198,7 @@ export const BusyStates = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate', exact: true })).toBeDisabled(); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeDisabled(); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeEnabled(); await expect(canvas.getByRole('button', { name: 'Generate', exact: true })).toHaveAttribute( 'aria-disabled', 'true' diff --git a/apps/web/src/stories/test-data-population-toolbar.stories.js b/apps/web/src/stories/test-data-population-toolbar.stories.js index aaec013e..6a2ec3af 100644 --- a/apps/web/src/stories/test-data-population-toolbar.stories.js +++ b/apps/web/src/stories/test-data-population-toolbar.stories.js @@ -27,6 +27,7 @@ function renderToolbarStory(args) { pairwiseVisible: args.pairwiseVisible, generateBusy: args.generateBusy, generatePairwiseBusy: args.generatePairwiseBusy, + generateSchemaBusy: args.generateSchemaBusy, modeOptions: [ { value: 'new-table', label: 'New Table' }, { value: 'amend-table', label: 'Amend Table' }, @@ -49,6 +50,10 @@ function renderToolbarStory(args) { args.onGenerateCombinations?.(); log.textContent = 'action:generate-combinations'; }, + onGenerateSchemaFromGrid: () => { + args.onGenerateSchema?.(); + log.textContent = 'action:generate-schema'; + }, onModeChange: (mode) => { args.onModeChange?.(mode); log.textContent = `selected:${mode}`; @@ -87,10 +92,12 @@ const meta = { pairwiseVisible: false, generateBusy: false, generatePairwiseBusy: false, + generateSchemaBusy: false, rowCount: 1, maxWidth: '', onGenerate: fn(), onGenerateCombinations: fn(), + onGenerateSchema: fn(), onModeChange: fn(), }, argTypes: { @@ -111,6 +118,10 @@ const meta = { control: 'boolean', description: 'Disables the combinations action when true.', }, + generateSchemaBusy: { + control: 'boolean', + description: 'Disables the Grid to Enum Schema action when true.', + }, rowCount: { control: 'number', description: 'Initial count shown by the shared row-count control.', @@ -137,6 +148,7 @@ export const Default = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate' })).toBeVisible(); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeVisible(); await expect(canvas.getByRole('spinbutton', { name: 'How Many?' })).toHaveValue(1); await expect(canvas.getByRole('radio', { name: 'New Table' })).toBeChecked(); await userEvent.click(canvas.getByRole('button', { name: 'Generate' })); @@ -149,6 +161,7 @@ export const PairwiseAndBusy = { pairwiseVisible: true, generateBusy: true, generatePairwiseBusy: true, + generateSchemaBusy: true, selectedMode: 'amend-selected', rowCount: 3, }, @@ -164,6 +177,7 @@ export const PairwiseAndBusy = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate' })).toBeDisabled(); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeDisabled(); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeDisabled(); await expect(canvas.getByRole('radio', { name: 'Amend Selected' })).toBeChecked(); }, }; diff --git a/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js b/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js index 50ba916b..8b23fdda 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js @@ -18,11 +18,11 @@ class ConfirmDialogComponent { await expect(this.backdrop).toBeHidden(); } - async confirmIfVisible() { + async confirmIfVisible(options = {}) { if (!(await this.isVisible())) { return false; } - await this.confirm(); + await this.confirm(options); return true; } @@ -34,9 +34,9 @@ class ConfirmDialogComponent { return true; } - async confirm() { + async confirm({ confirmLabel = /^ok$/i } = {}) { await this.expectVisible(); - await this.backdrop.getByRole('button', { name: /^ok$/i }).click(); + await this.backdrop.getByRole('button', { name: confirmLabel }).click(); await this.expectHidden(); } diff --git a/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js b/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js index 4959f2dd..c9c82786 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js @@ -1,5 +1,7 @@ const { expect } = require('@playwright/test'); const { GridRendererComponent } = require('./grid-renderer.component'); +const { ConfirmDialogComponent } = require('./confirm-dialog.component'); +const { TextInputDialogComponent } = require('./text-input-dialog.component'); const { SchemaEditorComponent } = require('../../../shared/abstractions/components/schema-editor.component'); const { OverlaySafeActivationComponent, @@ -14,6 +16,7 @@ class TestDataPanelComponent { this.heading = this.container.getByText('Test Data', { exact: true }); this.generateButton = this.container.getByRole('button', { name: 'Generate', exact: true }); this.generatePairwiseButton = this.container.getByRole('button', { name: 'Generate Combinations' }); + this.generateSchemaButton = this.container.getByRole('button', { name: 'Grid to Enum Schema', exact: true }); this.combinationsDialog = page.getByRole('dialog', { name: 'Generate Combinations' }); this.combinationsDialogStrengthSelect = this.combinationsDialog.getByLabel('n'); this.combinationsDialogCancelButton = this.combinationsDialog.getByRole('button', { name: 'Cancel' }); @@ -26,6 +29,8 @@ class TestDataPanelComponent { this.deleteSelectedSchemaRowsButton = this.container.getByRole('button', { name: '- Delete Selected' }); this.selectedSchemaRowIndex = 0; this.overlaySafeActivation = new OverlaySafeActivationComponent(page); + this.confirmDialog = new ConfirmDialogComponent(page); + this.textInputDialog = new TextInputDialogComponent(page); this.schemaEditor = new SchemaEditorComponent(page, { rootSelector: '[data-role="data-population-panel-root"]', fieldMap: { @@ -134,6 +139,15 @@ class TestDataPanelComponent { await expect(this.combinationsDialog).toBeVisible(); } + async openGridToEnumSchemaDialog() { + await this.overlaySafeActivation.activateButton(this.generateSchemaButton); + await this.textInputDialog.expectVisible(); + } + + async submitGridToEnumSchemaLimit(value) { + await this.textInputDialog.submit(value, { submitLabel: /build schema/i }); + } + async cancelGenerateCombinationsDialog() { await this.combinationsDialogCancelButton.click(); await expect(this.combinationsDialog).toBeHidden(); diff --git a/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js b/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js index 81b78c1b..70a9d834 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js @@ -4,7 +4,7 @@ class TextInputDialogComponent { constructor(page) { this.page = page; this.backdrop = page.locator('#text-input-modal-backdrop'); - this.input = this.backdrop.getByRole('textbox'); + this.input = this.backdrop.locator('[data-role="text-input-dialog-field"]'); } async expectVisible() { @@ -15,10 +15,10 @@ class TextInputDialogComponent { await expect(this.backdrop).toBeHidden(); } - async submit(value) { + async submit(value, { submitLabel = /^ok$/i } = {}) { await this.expectVisible(); await this.input.fill(String(value ?? '')); - await this.backdrop.getByRole('button', { name: /^ok$/i }).click(); + await this.backdrop.getByRole('button', { name: submitLabel }).click(); await this.expectHidden(); } } diff --git a/apps/web/src/tests/browser/app/functional/test-data/grid-to-enum-schema.spec.js b/apps/web/src/tests/browser/app/functional/test-data/grid-to-enum-schema.spec.js new file mode 100644 index 00000000..e4eb9f24 --- /dev/null +++ b/apps/web/src/tests/browser/app/functional/test-data/grid-to-enum-schema.spec.js @@ -0,0 +1,58 @@ +const { test } = require('@playwright/test'); +const { openApp, expectNoPageErrors, expect } = require('../../abstractions/helpers/scenario-helpers'); + +async function seedEnumGrid(appPage) { + await appPage.gridEditor.resetTable(); + await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(0); + + await appPage.gridEditor.header.renameColumn('~rename-me', 'Status'); + await appPage.gridEditor.header.addColumnRight('Status', 'Priority'); + + for (let rowIndex = 0; rowIndex < 4; rowIndex += 1) { + await appPage.gridEditor.addRow(); + } + await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(4); + + const statusValues = ['active', 'pending', 'active', 'inactive']; + const priorityValues = ['high', 'low', 'low', 'high']; + + for (let rowIndex = 0; rowIndex < statusValues.length; rowIndex += 1) { + await appPage.gridEditor.renderer.setCellTextByColumnName('Status', rowIndex, statusValues[rowIndex]); + await appPage.gridEditor.renderer.setCellTextByColumnName('Priority', rowIndex, priorityValues[rowIndex]); + } + + await appPage.gridEditor.renderer.waitForGridSettle({ columnName: 'Status', sampleSize: 4 }); +} + +test.describe('7. Test Data Generation', () => { + test('builds enum schema rows from the current grid', async ({ page }) => { + const { appPage, pageErrors } = await openApp(page); + + await seedEnumGrid(appPage); + await appPage.testDataPanel.expand(); + await appPage.testDataPanel.openGridToEnumSchemaDialog(); + await appPage.testDataPanel.submitGridToEnumSchemaLimit(256); + + await expect.poll(async () => appPage.testDataPanel.getSchemaRowCount()).toBe(2); + await expect + .poll(async () => appPage.testDataPanel.getSchemaText()) + .toContain('Status\nenum(active,pending,inactive)'); + await expect(await appPage.testDataPanel.getSchemaCell(0, 'columnName')).toBe('Status'); + await expect(await appPage.testDataPanel.getSchemaCell(1, 'columnName')).toBe('Priority'); + expectNoPageErrors(pageErrors); + }); + + test('confirms truncation when the requested enum limit is lower than current unique counts', async ({ page }) => { + const { appPage, pageErrors } = await openApp(page); + + await seedEnumGrid(appPage); + await appPage.testDataPanel.expand(); + await appPage.testDataPanel.openGridToEnumSchemaDialog(); + await appPage.testDataPanel.submitGridToEnumSchemaLimit(2); + await appPage.testDataPanel.confirmDialog.confirm({ confirmLabel: /truncate schema/i }); + + await expect.poll(async () => appPage.testDataPanel.getSchemaText()).toContain('Status\nenum(active,pending)'); + await expect.poll(async () => appPage.testDataPanel.getSchemaText()).not.toContain('inactive'); + expectNoPageErrors(pageErrors); + }); +}); diff --git a/docs-src/docs/040-test-data/015-data-grid-editable.md b/docs-src/docs/040-test-data/015-data-grid-editable.md index 9d678d2e..fa505560 100644 --- a/docs-src/docs/040-test-data/015-data-grid-editable.md +++ b/docs-src/docs/040-test-data/015-data-grid-editable.md @@ -32,6 +32,40 @@ The definition grid acts as a schema/template: Generated rows are inserted into the main editable data grid. +## Grid to Enum Schema + +The `Grid to Enum Schema` action scans the current main data grid and builds a schema definition from the values already in the table. + +Use this when you already have example rows in the editable grid and want to quickly turn those values into `Enum` generation rules. + +The generated schema uses: + +- one schema row per grid column +- the current column header as the schema row name +- `Enum` as the source type for every generated row +- unique values collected in first-seen row order + +### How it works + +1. Open the `Test Data` section in `app.html`. +2. Press `Grid to Enum Schema`. +3. Enter a maximum number of enum values to keep for each column. +4. Confirm truncation if any column has more unique values than the chosen limit. +5. Review the generated schema and press `Generate` or `Generate Pairwise` as needed. + +The limit prompt shows the largest unique-value count found in the scanned grid and uses a numeric input field to reduce entry mistakes. + +The default value is: + +- the largest column unique-value count when it is `256` or less +- `256` when the largest column has more than `256` unique values + +If the chosen limit is lower than the number of unique values in one or more columns then AnyWayData will ask for confirmation before truncating the schema. Truncation keeps the first values seen in current grid row order. + +When accepted, the generated enum schema replaces the existing schema definition in the Test Data editor. The schema grid, schema text area, validation state, and pairwise button visibility are all refreshed automatically. + +If the grid is empty, or no usable values can be extracted, then the existing schema is left unchanged. + ## Generate Modes in the Grid When generating in `app.html`, you can choose whether generation replaces data or amends existing rows: @@ -104,5 +138,3 @@ The schema in the Column Definition Data Grid will be used to generate the data. All data generation happens in the browser so the amount of data you can generate is limited only by the performance and memory of your computer. - - diff --git a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js index d39c6e00..25953e3c 100644 --- a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js +++ b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js @@ -8,6 +8,7 @@ class DataPopulationPanelController { rowCountProps: { ...(props.rowCountProps || {}) }, actionIds: { ...(props.actionIds || {}) }, schemaDefinitionProps: { ...(props.schemaDefinitionProps || {}) }, + generateSchemaBusy: props.generateSchemaBusy === true, }; } @@ -24,6 +25,7 @@ class DataPopulationPanelController { schemaDefinitionProps: nextProps.schemaDefinitionProps ? { ...this.state.schemaDefinitionProps, ...nextProps.schemaDefinitionProps } : this.state.schemaDefinitionProps, + generateSchemaBusy: nextProps.generateSchemaBusy ?? this.state.generateSchemaBusy, }; } diff --git a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js index 967ff766..937eb421 100644 --- a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js +++ b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js @@ -46,10 +46,12 @@ class DataPopulationPanelView { modeOptions: state.modeOptions, rowCountProps: state.rowCountProps, actionIds: state.actionIds, + generateSchemaBusy: state.generateSchemaBusy, }, callbacks: { onGenerate: this.callbacks.onGenerate, onGeneratePairwise: this.callbacks.onGeneratePairwise, + onGenerateSchemaFromGrid: this.callbacks.onGenerateSchemaFromGrid, onModeChange: (mode) => this.controller.handleModeChange(mode), }, }); @@ -79,6 +81,7 @@ class DataPopulationPanelView { modeOptions: state.modeOptions, rowCountProps: state.rowCountProps, actionIds: state.actionIds, + generateSchemaBusy: state.generateSchemaBusy, }); this.schemaPanel?.update?.({ className: 'test-data-schema-edit-zone shared-schema-section', @@ -123,6 +126,10 @@ class DataPopulationPanelView { this.toolbar?.setGeneratePairwiseBusy?.(isBusy); } + setGenerateSchemaBusy(isBusy) { + this.toolbar?.setGenerateSchemaBusy?.(isBusy); + } + getSchemaDefinition() { return this.schemaPanel?.getSchemaDefinition?.() || null; } diff --git a/packages/core-ui/js/gui_components/app/data-population-panel/index.js b/packages/core-ui/js/gui_components/app/data-population-panel/index.js index 9f52668e..dfef5385 100644 --- a/packages/core-ui/js/gui_components/app/data-population-panel/index.js +++ b/packages/core-ui/js/gui_components/app/data-population-panel/index.js @@ -72,6 +72,10 @@ function createDataPopulationPanelComponent({ root, props = {}, services = {}, c setGeneratePairwiseBusy(isBusy) { view.setGeneratePairwiseBusy(isBusy); }, + setGenerateSchemaBusy(isBusy) { + controller.updateProps({ generateSchemaBusy: isBusy === true }); + view.setGenerateSchemaBusy(isBusy); + }, validateSchemaRows({ syncFromText = true } = {}) { if (syncFromText) { const isTextMode = view.getSchemaDefinition()?.getState?.()?.isTextMode === true; @@ -90,6 +94,9 @@ function createDataPopulationPanelComponent({ root, props = {}, services = {}, c syncSchemaTextFromRows() { return view.getSchemaDefinition()?.syncTextFromRows?.(); }, + replaceSchemaRows(rows) { + return view.getSchemaDefinition()?.replaceRows?.(rows); + }, insertSampleSchema() { return view.getSchemaDefinition()?.insertSampleSchema?.(); }, diff --git a/packages/core-ui/js/gui_components/app/population-actions/index.js b/packages/core-ui/js/gui_components/app/population-actions/index.js index 272be3df..95ad0bf9 100644 --- a/packages/core-ui/js/gui_components/app/population-actions/index.js +++ b/packages/core-ui/js/gui_components/app/population-actions/index.js @@ -44,6 +44,10 @@ function createPopulationActionsComponent({ controller.updateProps({ generatePairwiseBusy: isBusy === true }); view.render(); }, + setGenerateSchemaBusy(isBusy) { + controller.updateProps({ generateSchemaBusy: isBusy === true }); + view.render(); + }, getState() { return controller.getState(); }, diff --git a/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js b/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js index 2043c90d..24ef42f0 100644 --- a/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js +++ b/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js @@ -5,17 +5,22 @@ class PopulationActionsController { pairwiseVisible: props.pairwiseVisible === true, generateBusy: props.generateBusy === true, generatePairwiseBusy: props.generatePairwiseBusy === true, + generateSchemaBusy: props.generateSchemaBusy === true, generateLabel: props.generateLabel || 'Generate', generatePairwiseLabel: props.generatePairwiseLabel || 'Generate Pairwise', + generateSchemaLabel: props.generateSchemaLabel || 'Grid to Enum Schema', generateHelpHtml: props.generateHelpHtml || '', generatePairwiseHelpHtml: props.generatePairwiseHelpHtml || '', + generateSchemaHelpHtml: props.generateSchemaHelpHtml || '', generateHelpLabel: props.generateHelpLabel || 'Show generate help', generatePairwiseHelpLabel: props.generatePairwiseHelpLabel || 'Show pairwise generation help', + generateSchemaHelpLabel: props.generateSchemaHelpLabel || 'Show grid to schema help', statusVisible: props.statusVisible === true, roleNames: { generateButton: props.roleNames?.generateButton || 'generate-button', generatePairwiseButton: props.roleNames?.generatePairwiseButton || 'generate-pairwise-button', generatePairwiseWrapper: props.roleNames?.generatePairwiseWrapper || 'generate-pairwise-button-wrapper', + generateSchemaButton: props.roleNames?.generateSchemaButton || 'generate-schema-button', status: props.roleNames?.status || 'population-status', }, }; @@ -27,12 +32,16 @@ class PopulationActionsController { pairwiseVisible: nextProps.pairwiseVisible ?? this.state.pairwiseVisible, generateBusy: nextProps.generateBusy ?? this.state.generateBusy, generatePairwiseBusy: nextProps.generatePairwiseBusy ?? this.state.generatePairwiseBusy, + generateSchemaBusy: nextProps.generateSchemaBusy ?? this.state.generateSchemaBusy, generateLabel: nextProps.generateLabel ?? this.state.generateLabel, generatePairwiseLabel: nextProps.generatePairwiseLabel ?? this.state.generatePairwiseLabel, + generateSchemaLabel: nextProps.generateSchemaLabel ?? this.state.generateSchemaLabel, generateHelpHtml: nextProps.generateHelpHtml ?? this.state.generateHelpHtml, generatePairwiseHelpHtml: nextProps.generatePairwiseHelpHtml ?? this.state.generatePairwiseHelpHtml, + generateSchemaHelpHtml: nextProps.generateSchemaHelpHtml ?? this.state.generateSchemaHelpHtml, generateHelpLabel: nextProps.generateHelpLabel ?? this.state.generateHelpLabel, generatePairwiseHelpLabel: nextProps.generatePairwiseHelpLabel ?? this.state.generatePairwiseHelpLabel, + generateSchemaHelpLabel: nextProps.generateSchemaHelpLabel ?? this.state.generateSchemaHelpLabel, statusVisible: nextProps.statusVisible ?? this.state.statusVisible, roleNames: nextProps.roleNames ? { @@ -54,6 +63,10 @@ class PopulationActionsController { handleGeneratePairwise() { this.callbacks.onGeneratePairwise?.(); } + + handleGenerateSchemaFromGrid() { + this.callbacks.onGenerateSchemaFromGrid?.(); + } } export { PopulationActionsController }; diff --git a/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js b/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js index 0caf027c..71339b16 100644 --- a/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js +++ b/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js @@ -11,6 +11,7 @@ class PopulationActionsView { this.services = services; this.handleGenerate = () => this.controller.handleGenerate(); this.handleGeneratePairwise = () => this.controller.handleGeneratePairwise(); + this.handleGenerateSchemaFromGrid = () => this.controller.handleGenerateSchemaFromGrid(); } buildOptionalIdAttr(id) { @@ -43,12 +44,14 @@ class PopulationActionsView { this.generatePairwiseButton = this.root.querySelector( `[data-role="${this.getRoleName('generatePairwiseButton')}"]` ); + this.generateSchemaButton = this.root.querySelector(`[data-role="${this.getRoleName('generateSchemaButton')}"]`); this.generatePairwiseWrapper = this.root.querySelector( `[data-role="${this.getRoleName('generatePairwiseWrapper')}"]` ); this.generateButton?.addEventListener('click', this.handleGenerate); this.generatePairwiseButton?.addEventListener('click', this.handleGeneratePairwise); + this.generateSchemaButton?.addEventListener('click', this.handleGenerateSchemaFromGrid); this.render(); } @@ -85,6 +88,16 @@ class PopulationActionsView { ${escapeHtml(state.generatePairwiseLabel)} + + ${this.renderHelpButton({ + helpHtml: state.generateSchemaHelpHtml, + ariaLabel: state.generateSchemaHelpLabel, + })} + + ${ state.statusVisible ? ` @@ -116,6 +129,11 @@ class PopulationActionsView { this.generatePairwiseButton.setAttribute('aria-disabled', state.generatePairwiseBusy === true ? 'true' : 'false'); this.generatePairwiseButton.setAttribute('aria-busy', state.generatePairwiseBusy === true ? 'true' : 'false'); } + if (this.generateSchemaButton) { + this.generateSchemaButton.disabled = state.generateSchemaBusy === true; + this.generateSchemaButton.setAttribute('aria-disabled', state.generateSchemaBusy === true ? 'true' : 'false'); + this.generateSchemaButton.setAttribute('aria-busy', state.generateSchemaBusy === true ? 'true' : 'false'); + } if (this.generatePairwiseWrapper) { this.generatePairwiseWrapper.style.display = state.pairwiseVisible ? 'inline-flex' : 'none'; } @@ -125,6 +143,7 @@ class PopulationActionsView { destroy() { this.generateButton?.removeEventListener('click', this.handleGenerate); this.generatePairwiseButton?.removeEventListener('click', this.handleGeneratePairwise); + this.generateSchemaButton?.removeEventListener('click', this.handleGenerateSchemaFromGrid); this.root.replaceChildren(); } } diff --git a/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js b/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js index 9d4d735e..bafd2c1a 100644 --- a/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js +++ b/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js @@ -48,6 +48,8 @@ import { createDataPopulationPanelComponent } from '../../data-population-panel/ import { resolveDocumentObj } from '../../../shared/dom/default-objects.js'; import { createCombinationsDialogComponent } from '../../../shared/combinations-dialog/index.js'; import { createConfirmDialogService } from '../../../shared/dialog-services/confirm-dialog-service.js'; +import { createTextInputDialogService } from '../../../shared/dialog-services/text-input-dialog-service.js'; +import { DEFAULT_ENUM_LIMIT, createEnumSchemaRowsFromGrid, normaliseEnumLimit } from '../schema/grid-to-enum-schema.js'; import { faker } from 'https://cdn.skypack.dev/@faker-js/faker@v9.7.0'; @@ -70,6 +72,8 @@ function createTestDataGenerationPanelManager({ createDataPopulationPanelComponentFn = createDataPopulationPanelComponent, createTestDataUiStatusServiceFn = createTestDataUiStatusService, createCombinationsDialogComponentFn = createCombinationsDialogComponent, + createConfirmDialogServiceFn = createConfirmDialogService, + createTextInputDialogServiceFn = createTextInputDialogService, } = {}) { const state = { debouncer: new DebouncerClass(), @@ -82,6 +86,7 @@ function createTestDataGenerationPanelManager({ actionAdapter: null, uiStatusService: null, combinationsDialog: null, + textInputDialogService: null, }; function showTestDataSchemaError(message) { @@ -149,7 +154,7 @@ function createTestDataGenerationPanelManager({ function createGenerationService() { const resolvedRandExpClass = getRandExpClass(); const statusServiceApi = getStatusServiceApi(); - const confirmDialogService = createConfirmDialogService({ documentObj: getResolvedDocument() }); + const confirmDialogService = createConfirmDialogServiceFn({ documentObj: getResolvedDocument() }); return createTestDataGenerationServiceFn({ schemaTextToDataRules: schemaTextToDataRulesFn, schemaRowsToSpec: (schemaRows) => @@ -188,6 +193,111 @@ function createTestDataGenerationPanelManager({ }); } + function ensureTextInputDialogService() { + if (!state.textInputDialogService) { + state.textInputDialogService = createTextInputDialogServiceFn({ documentObj: getResolvedDocument() }); + } + return state.textInputDialogService; + } + + async function generateEnumSchemaFromGrid() { + const statusServiceApi = getStatusServiceApi(); + const gridExtras = getMainGridExtras(); + const schemaDefinition = state.dataPopulationPanel?.getSchemaDefinition?.(); + const createBlankRow = state.dataPopulationPanel?.getState?.()?.schemaDefinitionProps?.createBlankRow; + + if (!gridExtras?.getGridAsGenericDataTable || !schemaDefinition || typeof createBlankRow !== 'function') { + showTestDataSchemaError('Grid to schema is not available.'); + statusServiceApi.setTestDataStatus('Unable to build schema from the grid.', { + severity: 'error', + dismissable: true, + }); + return false; + } + + state.dataPopulationPanel?.setGenerateSchemaBusy?.(true); + statusServiceApi.setTestDataLoadingStatus('Scanning grid for enum schema...'); + + try { + const dataTable = await Promise.resolve(gridExtras.getGridAsGenericDataTable()); + const initialSummary = createEnumSchemaRowsFromGrid({ + dataTable, + maxEnumValues: DEFAULT_ENUM_LIMIT, + createBlankRow, + }); + + if (initialSummary.usableColumns.length === 0) { + showTestDataSchemaError('No non-empty column values were found to build enum schema rows.'); + statusServiceApi.setTestDataStatus('No enum schema rows generated from the current grid.', { + severity: 'warning', + dismissable: true, + }); + return false; + } + + const suggestedLimit = Math.min(initialSummary.maxUniqueValueCount || DEFAULT_ENUM_LIMIT, DEFAULT_ENUM_LIMIT); + const requestedLimit = await ensureTextInputDialogService().requestTextInput({ + title: 'Grid to Enum Schema', + message: `- largest Column has ${initialSummary.maxUniqueValueCount} unique values`, + label: 'Limit imported enum(s) to max size of', + initialValue: String(suggestedLimit), + okLabel: 'Build Schema', + cancelLabel: 'Cancel', + inputType: 'number', + min: 1, + step: 1, + }); + if (requestedLimit === null) { + statusServiceApi.setTestDataStatus('Grid to enum schema cancelled.', { + severity: 'warning', + dismissable: true, + }); + return false; + } + + const maxEnumValues = normaliseEnumLimit(requestedLimit); + const summary = createEnumSchemaRowsFromGrid({ + dataTable, + maxEnumValues, + createBlankRow, + }); + + if (summary.truncatedColumnCount > 0) { + const confirmDialogService = createConfirmDialogServiceFn({ documentObj: getResolvedDocument() }); + const shouldTruncate = await confirmDialogService.requestConfirm({ + title: 'Confirm enum truncation', + message: `Enum limit ${maxEnumValues} is less than current values. Truncate to first ${maxEnumValues} values for affected columns?`, + okLabel: 'Truncate Schema', + cancelLabel: 'Cancel', + }); + confirmDialogService.destroy(); + if (!shouldTruncate) { + statusServiceApi.setTestDataStatus('Grid to enum schema cancelled.', { + severity: 'warning', + dismissable: true, + }); + return false; + } + } + + const validation = state.dataPopulationPanel?.replaceSchemaRows?.(summary.rows) || { errors: [] }; + if (validation.errors?.length > 0) { + statusServiceApi.setTestDataStatus('Schema replacement completed with validation warnings.', { + severity: 'warning', + dismissable: true, + }); + } else { + statusServiceApi.setTestDataStatus( + `Created ${summary.rows.length} enum schema rows${summary.truncatedColumnCount > 0 ? ` with truncation in ${summary.truncatedColumnCount} column${summary.truncatedColumnCount === 1 ? '' : 's'}` : ''}.`, + { dismissable: true } + ); + } + return true; + } finally { + state.dataPopulationPanel?.setGenerateSchemaBusy?.(false); + } + } + function ensureCombinationsDialog(resolvedDocument) { if (state.combinationsDialog) { return state.combinationsDialog; @@ -229,7 +339,9 @@ function createTestDataGenerationPanelManager({ state.dataPopulationPanel?.destroy?.(); state.uiStatusService?.destroy?.(); state.combinationsDialog?.destroy?.(); + state.textInputDialogService?.destroy?.(); state.combinationsDialog = null; + state.textInputDialogService = null; state.debouncer?.clear?.(); identifyFakerCommandsFn(faker); @@ -308,6 +420,7 @@ function createTestDataGenerationPanelManager({ callbacks: { onGenerate: () => state.actionAdapter.generateTestData(), onGeneratePairwise: () => openGenerateCombinationsDialog(), + onGenerateSchemaFromGrid: generateEnumSchemaFromGrid, onModeChange: applyModeDefaultRowCount, schemaDefinition: { onSchemaError: (message) => state.schemaTextSyncState?.schemaErrorDisplay?.show?.(message), @@ -331,7 +444,9 @@ function createTestDataGenerationPanelManager({ state.dataPopulationPanel?.destroy?.(); state.uiStatusService?.destroy?.(); state.combinationsDialog?.destroy?.(); + state.textInputDialogService?.destroy?.(); state.combinationsDialog = null; + state.textInputDialogService = null; state.debouncer?.clear?.(); }, }; diff --git a/packages/core-ui/js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js b/packages/core-ui/js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js new file mode 100644 index 00000000..1ddc9526 --- /dev/null +++ b/packages/core-ui/js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js @@ -0,0 +1,78 @@ +import { SOURCE_TYPE_ENUM } from '../../../shared/schema-row-rule-mapper.js'; + +const DEFAULT_ENUM_LIMIT = 256; + +function normaliseEnumLimit(limit, { defaultLimit = DEFAULT_ENUM_LIMIT } = {}) { + const parsed = Number.parseInt(limit, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return defaultLimit; + } + return parsed; +} + +function normaliseGridCellValue(value) { + return String(value ?? '').trim(); +} + +function buildGridEnumSchemaSummary({ dataTable, maxEnumValues = DEFAULT_ENUM_LIMIT } = {}) { + const headers = Array.isArray(dataTable?.getHeaders?.()) ? dataTable.getHeaders() : []; + const rowCount = Number.parseInt(dataTable?.getRowCount?.() ?? 0, 10) || 0; + const safeLimit = normaliseEnumLimit(maxEnumValues); + + const columns = headers.map((header, headerIndex) => { + const uniqueValues = []; + const seenValues = new Set(); + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + const cellValue = normaliseGridCellValue(dataTable?.getCell?.(rowIndex, headerIndex)); + if (!cellValue || seenValues.has(cellValue)) { + continue; + } + seenValues.add(cellValue); + uniqueValues.push(cellValue); + } + + const keptValues = uniqueValues.slice(0, safeLimit); + + return { + header: String(header ?? '').trim(), + uniqueValues, + uniqueValueCount: uniqueValues.length, + truncated: uniqueValues.length > keptValues.length, + keptValues, + }; + }); + + const usableColumns = columns.filter((column) => column.header.length > 0 && column.uniqueValueCount > 0); + const maxUniqueValueCount = usableColumns.reduce((largest, column) => Math.max(largest, column.uniqueValueCount), 0); + const truncatedColumnCount = usableColumns.filter((column) => column.truncated).length; + + return { + maxEnumValues: safeLimit, + maxUniqueValueCount, + truncatedColumnCount, + columns, + usableColumns, + }; +} + +function createEnumSchemaRowsFromGrid({ dataTable, maxEnumValues = DEFAULT_ENUM_LIMIT, createBlankRow } = {}) { + const summary = buildGridEnumSchemaSummary({ dataTable, maxEnumValues }); + const rows = summary.usableColumns.map((column) => ({ + ...(typeof createBlankRow === 'function' ? createBlankRow() : {}), + name: column.header, + sourceType: SOURCE_TYPE_ENUM, + command: '', + params: '', + value: column.keptValues.join(','), + comments: '', + leadingTextLines: [], + })); + + return { + ...summary, + rows, + }; +} + +export { DEFAULT_ENUM_LIMIT, buildGridEnumSchemaSummary, createEnumSchemaRowsFromGrid, normaliseEnumLimit }; diff --git a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js index c6510c76..917e0c2a 100644 --- a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js +++ b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js @@ -67,6 +67,10 @@ function createTestDataPopulationToolbarComponent({ controller.updateProps({ generatePairwiseBusy: isBusy === true }); view.setGeneratePairwiseBusy(isBusy); }, + setGenerateSchemaBusy(isBusy) { + controller.updateProps({ generateSchemaBusy: isBusy === true }); + view.setGenerateSchemaBusy(isBusy); + }, getState() { return controller.getState(); }, diff --git a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js index fb9028e4..0a236f40 100644 --- a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js +++ b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js @@ -9,6 +9,7 @@ class TestDataPopulationToolbarController { actionIds: { ...(props.actionIds || {}) }, generateBusy: props.generateBusy === true, generatePairwiseBusy: props.generatePairwiseBusy === true, + generateSchemaBusy: props.generateSchemaBusy === true, }; } @@ -24,6 +25,7 @@ class TestDataPopulationToolbarController { : this.state.rowCountProps, generateBusy: nextProps.generateBusy ?? this.state.generateBusy, generatePairwiseBusy: nextProps.generatePairwiseBusy ?? this.state.generatePairwiseBusy, + generateSchemaBusy: nextProps.generateSchemaBusy ?? this.state.generateSchemaBusy, }; } diff --git a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js index 81b26daf..638918f9 100644 --- a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js +++ b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js @@ -10,6 +10,10 @@ const GENERATE_PAIRWISE_TO_GRID_HELP_HTML = `

N-wise generation docs

`; +const GRID_TO_SCHEMA_HELP_HTML = ` +

Scan the current grid and build an enum-only schema from the visible column values.

+`; + class TestDataPopulationToolbarView { constructor({ root, controller, documentObj, services = {}, callbacks = {} } = {}) { this.root = root; @@ -60,11 +64,15 @@ class TestDataPopulationToolbarView { generateHelpHtml: GENERATE_TO_GRID_HELP_HTML, generatePairwiseHelpHtml: GENERATE_PAIRWISE_TO_GRID_HELP_HTML, generatePairwiseHelpLabel: 'Show combination generation help', + generateSchemaLabel: 'Grid to Enum Schema', + generateSchemaHelpHtml: GRID_TO_SCHEMA_HELP_HTML, + generateSchemaHelpLabel: 'Show grid to enum schema help', statusVisible: true, }, callbacks: { onGenerate: this.callbacks.onGenerate, onGeneratePairwise: this.callbacks.onGeneratePairwise, + onGenerateSchemaFromGrid: this.callbacks.onGenerateSchemaFromGrid, }, }); @@ -99,7 +107,11 @@ class TestDataPopulationToolbarView { generateHelpHtml: GENERATE_TO_GRID_HELP_HTML, generatePairwiseHelpHtml: GENERATE_PAIRWISE_TO_GRID_HELP_HTML, generatePairwiseHelpLabel: 'Show combination generation help', + generateSchemaLabel: 'Grid to Enum Schema', + generateSchemaHelpHtml: GRID_TO_SCHEMA_HELP_HTML, + generateSchemaHelpLabel: 'Show grid to enum schema help', statusVisible: true, + generateSchemaBusy: state.generateSchemaBusy, }); this.populationModeSelector?.update?.({ name: 'testDataGenerationMode', @@ -147,6 +159,10 @@ class TestDataPopulationToolbarView { setGeneratePairwiseBusy(isBusy) { this.populationActions?.setGeneratePairwiseBusy?.(isBusy); } + + setGenerateSchemaBusy(isBusy) { + this.populationActions?.setGenerateSchemaBusy?.(isBusy); + } } export { TestDataPopulationToolbarView }; diff --git a/packages/core-ui/js/gui_components/shared/modal-text-input.js b/packages/core-ui/js/gui_components/shared/modal-text-input.js index 2e5433db..6998cbcd 100644 --- a/packages/core-ui/js/gui_components/shared/modal-text-input.js +++ b/packages/core-ui/js/gui_components/shared/modal-text-input.js @@ -18,6 +18,8 @@ function ensureModalElements(documentObj = getDefaultDocumentObj()) { backdrop.innerHTML = `