From 5bab03ae9018a211b190b12111f73ef570820c18 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 01:10:41 +0100 Subject: [PATCH 01/10] Add guided schema params editor --- .../shared-schema-definition.stories.js | 48 +- .../generator/functional/schema-edit.spec.js | 19 + .../params-editor-dialog.component.js | 30 ++ .../components/schema-editor.component.js | 12 + apps/web/styles.css | 19 +- docs/frontend-component-migration-plan.md | 1 + .../shared/domain-command-help-metadata.js | 10 +- .../schema/shared-schema-editor-controller.js | 69 +++ .../schema/shared-schema-editor-ui.js | 18 +- .../test-data/ui/params-editor-modal.css | 207 ++++++++ .../test-data/ui/params-editor-modal.js | 444 ++++++++++++++++++ .../shared-schema-definition-view.test.js | 52 +- .../shared-schema-editor-controller.test.js | 59 +++ .../shared/shared-schema-editor-ui.test.js | 28 +- .../tests/utils/params-editor-modal.test.js | 147 ++++++ 15 files changed, 1155 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js create mode 100644 packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css create mode 100644 packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js create mode 100644 packages/core-ui/src/tests/utils/params-editor-modal.test.js diff --git a/apps/web/src/stories/shared-schema-definition.stories.js b/apps/web/src/stories/shared-schema-definition.stories.js index c90d44d0..9e61673b 100644 --- a/apps/web/src/stories/shared-schema-definition.stories.js +++ b/apps/web/src/stories/shared-schema-definition.stories.js @@ -1,4 +1,4 @@ -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import RandExp from 'randexp'; import { faker } from '@faker-js/faker'; import { @@ -423,3 +423,49 @@ export const CommandPicker = { await expect(commandInput?.value).toBe('helpers.fake'); }, }; + +export const ParamsDialog = { + render: renderSharedSchemaDefinitionStory, + args: { + initialRows: [ + { + id: 'enum-row', + name: 'Status', + sourceType: 'domain', + command: 'datatype.enum', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }, + ], + }, + parameters: { + docs: { + description: { + story: + 'Guided params editing flow for documented command params. Reviewers can open the params dialog, fill values through the structured table, and confirm the generated schema params text is applied back to the shared row editor.', + }, + }, + }, + play: async ({ canvasElement }) => { + expectSchemaModeVisible(canvasElement); + const initialRow = canvasElement.querySelector('.shared-schema-row'); + const paramsButton = initialRow.querySelector('[data-action="edit-params"]'); + expect(paramsButton).not.toBeNull(); + await userEvent.click(paramsButton); + + const dialog = within(document.body).getByRole('dialog', { name: /edit params for .*datatype\.enum/i }); + const dialogScope = within(dialog); + const valuesInput = dialogScope.getByRole('textbox', { name: /values value/i }); + await userEvent.type(valuesInput, 'active,inactive,pending'); + await expect(dialogScope.getByText('(active,inactive,pending)')).toBeTruthy(); + await userEvent.click(dialogScope.getByRole('button', { name: /^apply$/i })); + + await waitFor(() => + expect(canvasElement.querySelector('.shared-schema-row [data-field="params"]').value).toBe( + '(active,inactive,pending)' + ) + ); + }, +}; diff --git a/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js b/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js index 8efbc82f..2a515a78 100644 --- a/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js +++ b/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js @@ -212,6 +212,25 @@ test.describe('Generator Schema Editing', () => { expectNoPageErrors(pageErrors); }); + test('documented command params can be edited through the guided params dialog', async ({ page }) => { + const { generatorPage, pageErrors } = await openGenerator(page); + + await generatorPage.schema.setTextMode(false); + await generatorPage.schema.setRowName(0, 'Status'); + await generatorPage.schema.editor.setRowTypeValue(0, 'datatype.enum'); + await generatorPage.schema.editor.editRowParamsWithDialog(0, { + values: 'active,inactive,pending', + }); + + await expect(generatorPage.schema.row(0).locator('[data-action="pick-command"]')).toHaveText('datatype.enum'); + await expect(generatorPage.schema.row(0).locator('input[data-field="params"]')).toHaveValue( + '(active,inactive,pending)' + ); + await expect.poll(async () => generatorPage.schema.getSchemaText()).toContain('enum(active,inactive,pending)'); + + expectNoPageErrors(pageErrors); + }); + test('schema edit buttons states are correct across top middle and bottom rows', async ({ page }) => { const { generatorPage, pageErrors } = await openGenerator(page); diff --git a/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js b/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js new file mode 100644 index 00000000..f458db32 --- /dev/null +++ b/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js @@ -0,0 +1,30 @@ +const { expect } = require('@playwright/test'); + +class ParamsEditorDialogComponent { + constructor(page) { + this.page = page; + this.overlay = page.locator('[data-role="params-editor-overlay"]'); + this.applyButton = this.overlay.locator('[data-role="params-editor-apply-button"]'); + } + + async expectOpen() { + await expect(this.overlay.locator('[data-role="params-editor-dialog"]')).toBeVisible(); + } + + valueInput(name) { + return this.overlay.getByRole('textbox', { name: new RegExp(`^${name} value$`, 'i') }); + } + + async setValue(name, value) { + await this.expectOpen(); + await this.valueInput(name).fill(String(value)); + } + + async apply() { + await this.expectOpen(); + await this.applyButton.click(); + await expect(this.overlay).toHaveCount(0); + } +} + +module.exports = { ParamsEditorDialogComponent }; diff --git a/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js b/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js index 26055b12..ea5ef6b5 100644 --- a/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js +++ b/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js @@ -1,6 +1,7 @@ const { expect } = require('@playwright/test'); const { MethodPickerDialogComponent } = require('./method-picker-dialog.component'); const { OverlaySafeActivationComponent } = require('./overlay-safe-activation.component'); +const { ParamsEditorDialogComponent } = require('./params-editor-dialog.component'); class SchemaEditorComponent { constructor(page, config) { @@ -31,6 +32,7 @@ class SchemaEditorComponent { ? page.locator(this.config.addFieldSelector) : this.root.locator('[data-role="schema-add-field"]'); this.methodPicker = new MethodPickerDialogComponent(page); + this.paramsEditor = new ParamsEditorDialogComponent(page); } row(index) { @@ -171,6 +173,16 @@ class SchemaEditorComponent { await this.row(index).locator(`button[data-action="${action}"]`).click(); } + async editRowParamsWithDialog(index, valuesByName) { + await this.ensureSchemaMode(); + await this.dismissOpenHelpTooltips(); + await this.row(index).locator('[data-action="edit-params"]').click(); + for (const [name, value] of Object.entries(valuesByName || {})) { + await this.paramsEditor.setValue(name, value); + } + await this.paramsEditor.apply(); + } + async dragRowToIndex(fromIndex, toIndex, { placement = 'before' } = {}) { await this.ensureSchemaMode(); const source = this.row(fromIndex).locator('[data-action="drag"]'); diff --git a/apps/web/styles.css b/apps/web/styles.css index 3bcef639..281dbfa1 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -1865,7 +1865,7 @@ body.theme-dark .shared-schema-row-validation { align-self: center; margin: 0 0.2rem; } - .shared-schema-row > input[data-field='params'] { + .shared-schema-row > .shared-schema-params-control { grid-column: 1 / span 3; grid-row: 4; min-width: 0; @@ -1899,6 +1899,23 @@ body.theme-dark .shared-schema-row-validation { position: relative; } +.shared-schema-params-control { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + gap: 0.35rem; + align-items: center; +} + +.shared-schema-params-control > input[data-field='params'] { + min-width: 0; + width: 100%; +} + +.shared-schema-params-button[disabled] { + opacity: 0.55; + cursor: not-allowed; +} + .shared-schema-command-picker-shadow-select { position: absolute; left: -9999px; diff --git a/docs/frontend-component-migration-plan.md b/docs/frontend-component-migration-plan.md index d773a4c3..f7498169 100644 --- a/docs/frontend-component-migration-plan.md +++ b/docs/frontend-component-migration-plan.md @@ -305,6 +305,7 @@ Current status: - `SharedSchemaDefinition` now lives under `shared/schema-definition/` with a controller, view, and create-component factory. - The new component reuses the existing shared schema parsing, validation, row-editing, command-picker, drag/drop, and text-mode logic from `shared/test-data/schema/` instead of duplicating those rules. +- The shared schema row editor now also exposes a documented params-edit dialog for commands with parameter metadata, so app and generator hosts share the same guided param-entry flow and semantic validation path instead of leaving params as raw punctuation-only text entry. - The app test-data panel now mounts its schema editor through `SharedSchemaDefinition`, keeping the same DOM IDs so the rest of the app flow and browser tests continue to treat the schema surface as a black box. - The generator page now also mounts its live schema editor through `SharedSchemaDefinition`, while preserving the page-level `#generatorSchemaSection` host contract, row-level browser interactions, and text-mode generate/pairwise flows. - Generator runtime adoption also moved the shared text-mode syncing, method-picker command selection, semantic-validation caret preservation, and pairwise-button visibility behavior onto the shared component path. diff --git a/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js b/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js index 81c4db6e..7a1c8f53 100644 --- a/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js +++ b/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js @@ -12,7 +12,15 @@ const SYNTHETIC_DOMAIN_HELP = Object.freeze({ examples: ['datatype.enum(active,inactive,pending)'], exampleReturnValues: ['active', 'inactive', 'pending'], returnType: 'string', - args: [], + args: [ + { + name: 'values', + type: 'comma-separated list', + optional: false, + description: 'List of allowed enum values chosen at random during generation.', + example: 'active,inactive,pending', + }, + ], }, }); diff --git a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js index 11609b3f..dfc9f707 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js +++ b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js @@ -1,4 +1,5 @@ import { openMethodPickerModal } from '../ui/method-picker-modal.js'; +import { openParamsEditorModal } from '../ui/params-editor-modal.js'; import { buildSchemaHelpModel, renderSchemaHelpHtml } from '../help/help-model-builder.js'; import { SOURCE_TYPE_DOMAIN, @@ -8,6 +9,7 @@ import { SOURCE_TYPE_REGEX, normaliseDomainCommand, normaliseFakerCommand, + normaliseSourceType, buildDataRuleFromSchemaRow, } from '../../schema-row-rule-mapper.js'; import { @@ -166,9 +168,42 @@ function createSharedSchemaEditorController({ title: model.title || '', docsUrl: model.docsUrl || '#', html: renderSchemaHelpHtml(model), + params: Array.isArray(model.params) ? model.params : [], }; }; + const getMethodOptionForRow = (row) => { + const command = String(row?.command || '').trim(); + if (!command) { + return null; + } + const sourceType = normaliseSourceType(row?.sourceType); + return (getMethodPickerOptions(command) || []).find( + (option) => + String(option?.command || '').trim() === command && normaliseSourceType(option?.sourceType) === sourceType + ); + }; + + const validateParamsForRow = (row, paramsText) => { + const rowIndex = session.getRows().findIndex((entry) => entry.id === row?.id); + if (rowIndex < 0) { + return []; + } + const candidateRow = { + ...row, + params: paramsText, + semanticValidationIssues: [], + }; + return getSchemaRowSemanticValidationIssues(candidateRow, rowIndex, { + schemaTextToDataRules, + faker, + RandExp, + includeBracketGuidance: false, + }) + .map((issue) => issue?.message) + .filter(Boolean); + }; + const revalidateRows = () => { if (typeof validateSchemaRows !== 'function') { return { rows: session.getRows(), errors: [] }; @@ -553,6 +588,40 @@ function createSharedSchemaEditorController({ return; } + const paramsButton = event?.target?.closest?.('[data-action="edit-params"]'); + if (paramsButton) { + const rowId = paramsButton.getAttribute('data-row-id'); + const index = session.getRows().findIndex((entry) => entry.id === rowId); + if (index >= 0) { + const row = session.getRows()[index]; + const methodOption = getMethodOptionForRow(row); + try { + const paramsText = await openParamsEditorModal({ + documentObj, + windowObj: resolveWindowObj(null, documentObj), + commandLabel: methodOption?.helpModel?.heading || row.command, + helpModel: methodOption?.helpModel || buildSchemaHelpModel(row.sourceType, row.command), + initialParams: row.params, + validateParams: (nextParamsText) => validateParamsForRow(row, nextParamsText), + }); + if (paramsText !== null) { + session.updateRowAtIndex(index, (currentRow) => ({ + ...currentRow, + params: paramsText, + semanticValidationIssues: [], + })); + revalidateRows(); + renderRows(); + syncTextFromRows(); + scheduleSemanticValidationForRow(rowId, { immediate: true }); + } + } catch { + return; + } + } + return; + } + handleSharedSchemaRowButtonClick({ event, schemaRows: session.getRows(), diff --git a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js index 84975162..d3dfd546 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js +++ b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js @@ -35,6 +35,8 @@ const SHARED_SCHEMA_COMMAND_PICKER_BUTTON_CLASS = 'shared-schema-command-picker- const SHARED_SCHEMA_COMMAND_PICKER_SHADOW_SELECT_CLASS = 'shared-schema-command-picker-shadow-select'; const SHARED_SCHEMA_COMMAND_ROW_CLASS = 'shared-schema-row-command'; const SHARED_SCHEMA_VALUE_ROW_CLASS = 'shared-schema-row-value'; +const SHARED_SCHEMA_PARAMS_CONTROL_CLASS = 'shared-schema-params-control'; +const SHARED_SCHEMA_PARAMS_BUTTON_CLASS = 'shared-schema-params-button'; const SHARED_SCHEMA_ROW_SELECTOR = '.shared-schema-row'; const SHARED_SCHEMA_ROWS_SELECTOR = '.shared-schema-rows'; @@ -78,6 +80,7 @@ function renderSharedSchemaRows({ const hasCommandValidationError = validationIssues.some((issue) => issue?.field === 'command'); const hasParamsValidationError = validationIssues.some((issue) => issue?.field === 'params'); const schemaHelp = getSchemaHelpData(normalisedSourceType, row.command); + const hasDocumentedParams = Array.isArray(schemaHelp?.params) && schemaHelp.params.length > 0; const rowElem = resolvedDocument.createElement('div'); rowElem.className = `${SHARED_SCHEMA_ROW_CLASS} ${ isCommandSource ? SHARED_SCHEMA_COMMAND_ROW_CLASS : SHARED_SCHEMA_VALUE_ROW_CLASS @@ -131,7 +134,18 @@ function renderSharedSchemaRows({ > ${ isCommandSource - ? `` + ? `
+ + +
` : `` } ${ @@ -330,6 +344,8 @@ export { SHARED_SCHEMA_COMMAND_PICKER_SHADOW_SELECT_CLASS, SHARED_SCHEMA_COMMAND_ROW_CLASS, SHARED_SCHEMA_VALUE_ROW_CLASS, + SHARED_SCHEMA_PARAMS_CONTROL_CLASS, + SHARED_SCHEMA_PARAMS_BUTTON_CLASS, SHARED_SCHEMA_ROW_SELECTOR, SHARED_SCHEMA_ROWS_SELECTOR, hideVisibleHelpTooltips, diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css new file mode 100644 index 00000000..65cdf670 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css @@ -0,0 +1,207 @@ +body.theme-light { + --pe-bg: #ffffff; + --pe-elevated: #f7f8fb; + --pe-border: #d6dce8; + --pe-text: #121620; + --pe-muted: #495066; + --pe-accent: #1f6feb; + --pe-accent-soft: #e9f1ff; + --pe-focus: #1f6feb; + --pe-overlay: rgba(15, 23, 42, 0.45); + --pe-error: #b42318; + --pe-error-soft: #fef3f2; + --pe-warning: #92400e; + --pe-warning-soft: #fff7ed; + --pe-code-bg: #eef2f8; +} + +body.theme-dark { + --pe-bg: #121722; + --pe-elevated: #1b2231; + --pe-border: #2e3a51; + --pe-text: #ebf0ff; + --pe-muted: #cfd8ef; + --pe-accent: #73a7ff; + --pe-accent-soft: #22314c; + --pe-focus: #8bb6ff; + --pe-overlay: rgba(0, 0, 0, 0.62); + --pe-error: #ffb4ab; + --pe-error-soft: #3b1720; + --pe-warning: #ffd8a8; + --pe-warning-soft: #3a2613; + --pe-code-bg: #1f2738; +} + +.params-editor-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: var(--pe-overlay, rgba(0, 0, 0, 0.55)); + z-index: 6100; +} + +.params-editor-modal { + width: min(980px, 100%); + max-height: calc(100vh - 40px); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--pe-border, #ddd); + border-radius: 12px; + background: var(--pe-bg, #fff); + color: var(--pe-text, #111); +} + +.params-editor-header, +.params-editor-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; +} + +.params-editor-header { + border-bottom: 1px solid var(--pe-border, #ddd); +} + +.params-editor-header h3, +.params-editor-subtitle, +.params-editor-summary { + margin: 0; +} + +.params-editor-subtitle { + margin-top: 4px; + color: var(--pe-muted, #555); +} + +.params-editor-close, +.params-editor-footer button, +.params-editor-table input, +.params-editor-table select { + border: 1px solid var(--pe-border, #ddd); + border-radius: 8px; + background: var(--pe-bg, #fff); + color: var(--pe-text, #111); +} + +.params-editor-close { + width: 32px; + height: 32px; + font-size: 20px; + line-height: 1; + cursor: pointer; +} + +.params-editor-body { + padding: 16px; + overflow: auto; +} + +.params-editor-summary { + color: var(--pe-muted, #555); + line-height: 1.45; +} + +.params-editor-warning, +.params-editor-error { + margin: 12px 0 0; + padding: 10px 12px; + border-radius: 10px; +} + +.params-editor-warning { + color: var(--pe-warning, #8a4b00); + background: var(--pe-warning-soft, #fff7ed); +} + +.params-editor-error { + color: var(--pe-error, #b42318); + background: var(--pe-error-soft, #fef3f2); +} + +.params-editor-table-wrap { + margin-top: 14px; + overflow: auto; +} + +.params-editor-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--pe-border, #ddd); + table-layout: fixed; +} + +.params-editor-table th, +.params-editor-table td { + padding: 8px; + border-bottom: 1px solid var(--pe-border, #ddd); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +.params-editor-table th { + background: var(--pe-elevated, #f7f7f7); + font-size: 12px; + letter-spacing: 0.02em; +} + +.params-editor-table tr:last-child td { + border-bottom: 0; +} + +.params-editor-table input, +.params-editor-table select { + width: 100%; + padding: 8px 10px; +} + +.params-editor-preview-block { + margin-top: 14px; +} + +.params-editor-preview { + display: block; + min-height: 44px; + margin-top: 8px; + padding: 10px 12px; + border: 1px solid var(--pe-border, #ddd); + border-radius: 10px; + background: var(--pe-code-bg, #f2f2f2); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.params-editor-footer { + justify-content: flex-end; + border-top: 1px solid var(--pe-border, #ddd); +} + +.params-editor-apply { + border-color: var(--pe-accent, #3366ff) !important; + background: var(--pe-accent, #3366ff) !important; + color: #fff !important; +} + +.params-editor-modal button:focus-visible, +.params-editor-modal input:focus-visible, +.params-editor-modal select:focus-visible { + outline: 2px solid var(--pe-focus, #4a80ff); + outline-offset: 2px; +} + +@media (max-width: 860px) { + .params-editor-modal { + width: 100%; + } + + .params-editor-table { + min-width: 720px; + } +} diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js new file mode 100644 index 00000000..c1e58a2a --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js @@ -0,0 +1,444 @@ +import { getDefaultDocumentObj, getDefaultWindowObj, resolveWindowObj } from '../../dom/default-objects.js'; + +const STYLE_ID = 'params-editor-modal-styles-link'; + +function escapeHtml(text) { + return String(text ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function ensureStyles(documentObj) { + if (!documentObj?.head || documentObj.getElementById(STYLE_ID)) { + return; + } + const link = documentObj.createElement('link'); + link.id = STYLE_ID; + link.rel = 'stylesheet'; + link.href = new URL('./params-editor-modal.css', import.meta.url).href; + documentObj.head.appendChild(link); +} + +function stripOuterParens(text) { + const value = String(text ?? '').trim(); + if (value.startsWith('(') && value.endsWith(')') && value.length >= 2) { + return value.slice(1, -1); + } + return value; +} + +function splitTopLevelCommaSeparated(text) { + const value = String(text ?? ''); + const items = []; + let current = ''; + let quote = ''; + let depthParen = 0; + let depthBracket = 0; + let depthBrace = 0; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + const previous = value[index - 1]; + + if (quote) { + current += char; + if (char === quote && previous !== '\\') { + quote = ''; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + + if (char === '(') depthParen += 1; + if (char === ')') depthParen -= 1; + if (char === '[') depthBracket += 1; + if (char === ']') depthBracket -= 1; + if (char === '{') depthBrace += 1; + if (char === '}') depthBrace -= 1; + + if (depthParen < 0 || depthBracket < 0 || depthBrace < 0) { + return { values: [], error: 'Current params contain unmatched closing brackets.' }; + } + + if (char === ',' && depthParen === 0 && depthBracket === 0 && depthBrace === 0) { + items.push(current.trim()); + current = ''; + continue; + } + + current += char; + } + + if (quote) { + return { values: [], error: 'Current params contain an unclosed quoted value.' }; + } + if (depthParen !== 0 || depthBracket !== 0 || depthBrace !== 0) { + return { values: [], error: 'Current params contain unbalanced brackets.' }; + } + + const finalValue = current.trim(); + if (finalValue.length > 0 || items.length > 0) { + items.push(finalValue); + } + return { values: items.filter((item, index, array) => item.length > 0 || index < array.length - 1), error: '' }; +} + +function unquoteValue(value) { + const trimmed = String(value ?? '').trim(); + if (trimmed.length < 2) { + return trimmed; + } + const quote = trimmed[0]; + if ((quote !== '"' && quote !== "'") || trimmed[trimmed.length - 1] !== quote) { + return trimmed; + } + if (quote === '"') { + try { + return JSON.parse(trimmed); + } catch { + return trimmed.slice(1, -1).replace(/\\"/g, '"'); + } + } + return trimmed.slice(1, -1).replace(/\\'/g, "'"); +} + +function inferEditorMode(rawValue, paramType = '') { + const trimmed = String(rawValue ?? '').trim(); + if (!trimmed) { + return 'auto'; + } + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return 'text'; + } + const type = String(paramType || '').toLowerCase(); + if ( + trimmed.startsWith('[') || + trimmed.startsWith('{') || + trimmed.includes('=') || + /^-?\d+(?:\.\d+)?$/u.test(trimmed) || + /^(true|false|null|undefined)$/iu.test(trimmed) || + /\b(array|list|object|json|record|map|tuple|regex|function|expression|comma-separated)\b/u.test(type) + ) { + return 'raw'; + } + return 'auto'; +} + +function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { + const metadata = Array.isArray(params) ? params : []; + const trimmed = stripOuterParens(initialParams); + if (!trimmed) { + return { + entries: metadata.map((param) => ({ + name: param?.name || '', + type: param?.type || '', + optional: param?.optional === true, + example: param?.example || '', + value: '', + mode: 'auto', + })), + error: '', + }; + } + + const split = splitTopLevelCommaSeparated(trimmed); + if (split.error) { + return { entries: [], error: split.error }; + } + if (split.values.length > metadata.length) { + return { + entries: [], + error: + 'Current params use more values than the documented fields. Edit the raw params text directly for this command.', + }; + } + + return { + entries: metadata.map((param, index) => { + const rawValue = split.values[index] || ''; + return { + name: param?.name || '', + type: param?.type || '', + optional: param?.optional === true, + example: param?.example || '', + value: rawValue ? unquoteValue(rawValue) : '', + mode: inferEditorMode(rawValue, param?.type || ''), + }; + }), + error: '', + }; +} + +function isRawPreferredType(paramType = '') { + return /\b(number|int|integer|float|double|decimal|bool|boolean|array|list|object|json|record|map|tuple|regex|function|expression|comma-separated)\b/iu.test( + String(paramType || '') + ); +} + +function validateBalancedRawValue(value) { + return splitTopLevelCommaSeparated(`[${String(value ?? '').trim()}]`).error.replace(/^Current params/u, 'Raw value'); +} + +function formatEditorValue(value, mode, paramType = '') { + const rawValue = String(value ?? ''); + if (rawValue.trim().length === 0) { + return ''; + } + + const resolvedMode = mode === 'auto' ? (isRawPreferredType(paramType) ? 'raw' : 'text') : mode; + if (resolvedMode === 'raw') { + return rawValue.trim(); + } + return JSON.stringify(rawValue); +} + +function buildParamsTextFromEditorEntries({ entries = [], validateParams = null } = {}) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + const lastFilledIndex = normalizedEntries.reduce( + (lastIndex, entry, index) => (String(entry?.value ?? '').trim().length > 0 ? index : lastIndex), + -1 + ); + const errors = []; + + if (lastFilledIndex < 0) { + const requiredMissing = normalizedEntries.some((entry) => entry?.optional !== true); + if (requiredMissing) { + errors.push('Add a value for each required param before applying.'); + } + return { + paramsText: '', + errors, + }; + } + + const formattedValues = []; + for (let index = 0; index <= lastFilledIndex; index += 1) { + const entry = normalizedEntries[index] || {}; + const rawValue = String(entry.value ?? ''); + const trimmedValue = rawValue.trim(); + if (!trimmedValue) { + errors.push(`Param ${entry.name || index + 1} must be filled before later params can be used.`); + continue; + } + if (entry.mode === 'raw') { + const rawError = validateBalancedRawValue(trimmedValue); + if (rawError) { + errors.push(`${entry.name || `Param ${index + 1}`}: ${rawError}.`); + continue; + } + } + formattedValues.push(formatEditorValue(rawValue, entry.mode || 'auto', entry.type || '')); + } + + const paramsText = formattedValues.length > 0 ? `(${formattedValues.join(',')})` : ''; + const validationResult = typeof validateParams === 'function' ? validateParams(paramsText) : []; + const semanticErrors = Array.isArray(validationResult) + ? validationResult + : validationResult + ? [String(validationResult)] + : []; + + return { + paramsText, + errors: [...errors, ...semanticErrors.filter(Boolean)], + }; +} + +function renderEntryRows(entries = []) { + return entries + .map( + (entry, index) => ` + + + ${escapeHtml(entry.type || 'unknown')} + ${entry.optional ? 'Optional' : 'Required'} + + + + + + + + ` + ) + .join(''); +} + +function openParamsEditorModal({ + documentObj = getDefaultDocumentObj(), + windowObj = getDefaultWindowObj(), + commandLabel = 'Params', + helpModel = {}, + initialParams = '', + validateParams = null, +} = {}) { + if (!documentObj?.body || typeof documentObj.createElement !== 'function') { + return Promise.resolve(null); + } + windowObj = resolveWindowObj(windowObj, documentObj); + ensureStyles(documentObj); + + const parsed = parseInitialParamEntries({ + params: helpModel?.params || [], + initialParams, + }); + const overlay = documentObj.createElement('div'); + overlay.className = 'params-editor-overlay'; + overlay.setAttribute('data-role', 'params-editor-overlay'); + overlay.innerHTML = ` + + `; + + const previewElement = overlay.querySelector('[data-role="params-editor-preview"]'); + const errorElement = overlay.querySelector('[data-role="params-editor-error"]'); + const applyButton = overlay.querySelector('[data-role="params-editor-apply-button"]'); + const valueInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-value"]')); + const modeInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-mode"]')); + const warningVisible = Boolean(parsed.error); + let currentEntries = parsed.entries.map((entry) => ({ ...entry })); + + function syncPreview() { + if (warningVisible) { + previewElement.textContent = initialParams || ''; + errorElement.textContent = parsed.error; + applyButton.disabled = true; + applyButton.setAttribute('aria-disabled', 'true'); + return; + } + + currentEntries = currentEntries.map((entry, index) => ({ + ...entry, + value: valueInputs()[index]?.value ?? entry.value, + mode: modeInputs()[index]?.value ?? entry.mode, + })); + const result = buildParamsTextFromEditorEntries({ + entries: currentEntries, + validateParams, + }); + previewElement.textContent = result.paramsText || '()'; + errorElement.textContent = result.errors[0] || ''; + const hasErrors = result.errors.length > 0; + applyButton.disabled = hasErrors; + applyButton.setAttribute('aria-disabled', hasErrors ? 'true' : 'false'); + return result; + } + + return new Promise((resolve) => { + function close(result) { + overlay.remove(); + resolve(result ?? null); + } + + overlay.addEventListener('click', (event) => { + const closeButton = event.target?.closest?.('[data-role="params-editor-close-button"]'); + const cancelButton = event.target?.closest?.('[data-role="params-editor-cancel-button"]'); + const applyActionButton = event.target?.closest?.('[data-role="params-editor-apply-button"]'); + if (event.target === overlay || closeButton || cancelButton) { + close(null); + return; + } + if (applyActionButton) { + const result = syncPreview(); + if (result?.errors?.length) { + return; + } + close(result?.paramsText || ''); + } + }); + + overlay.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + close(null); + return; + } + if (event.key === 'Enter' && !applyButton.disabled) { + const tagName = String(event.target?.tagName || '').toLowerCase(); + if (tagName === 'input' || tagName === 'select') { + event.preventDefault(); + const result = syncPreview(); + if (!result?.errors?.length) { + close(result?.paramsText || ''); + } + } + } + }); + + valueInputs().forEach((input) => input.addEventListener('input', syncPreview)); + modeInputs().forEach((select) => select.addEventListener('change', syncPreview)); + + documentObj.body.appendChild(overlay); + syncPreview(); + const firstInput = valueInputs()[0]; + const focusFn = windowObj?.requestAnimationFrame?.bind(windowObj) || windowObj?.setTimeout?.bind(windowObj); + focusFn?.(() => firstInput?.focus?.()); + }); +} + +export { + splitTopLevelCommaSeparated, + parseInitialParamEntries, + inferEditorMode, + buildParamsTextFromEditorEntries, + openParamsEditorModal, +}; diff --git a/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js b/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js index 88cf684f..d14cec70 100644 --- a/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js +++ b/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js @@ -1,6 +1,6 @@ import { jest } from '@jest/globals'; import { JSDOM } from 'jsdom'; -import { fireEvent } from '@testing-library/dom'; +import { fireEvent, within } from '@testing-library/dom'; import { schemaTextToDataRules, dataRulesToSchemaText, @@ -41,6 +41,7 @@ describe('shared-schema-definition view', () => { dom = new JSDOM('
'); global.window = dom.window; global.document = dom.window.document; + global.navigator = dom.window.navigator; global.Event = dom.window.Event; tippyInstances = []; global.tippy = jest.fn((elements, options) => { @@ -64,6 +65,7 @@ describe('shared-schema-definition view', () => { dom.window.close(); delete global.window; delete global.document; + delete global.navigator; delete global.Event; delete global.tippy; delete dom.window.tippy; @@ -103,7 +105,6 @@ describe('shared-schema-definition view', () => { row.leadingTextLines = Array.isArray(leadingTextLines) ? leadingTextLines.slice() : []; return row; }, - getMethodPickerOptions: () => [], getVisibleDomainCommands: () => ['string.counterString'], fakerCommands: ['helpers.arrayElement'], sampleSchemaText: TEST_DATA_GRID_SAMPLE_SCHEMA_TEXT, @@ -112,6 +113,22 @@ describe('shared-schema-definition view', () => { ? '

Edit as Schema

' : '

Edit as Text

', validateSchemaRows, + getMethodPickerOptions: (currentValue = '') => { + if (currentValue === 'datatype.enum') { + return [ + { + sourceType: 'domain', + command: 'datatype.enum', + helpModel: { + heading: 'datatype.enum', + summary: 'Enum helper', + params: [{ name: 'values', type: 'comma-separated list', optional: false }], + }, + }, + ]; + } + return []; + }, }, callbacks: { onSchemaError: (message) => { @@ -147,7 +164,6 @@ describe('shared-schema-definition view', () => { row.leadingTextLines = Array.isArray(leadingTextLines) ? leadingTextLines.slice() : []; return row; }, - getMethodPickerOptions: () => [], getVisibleDomainCommands: () => ['string.counterString'], fakerCommands: ['helpers.arrayElement'], sampleSchemaText: TEST_DATA_GRID_SAMPLE_SCHEMA_TEXT, @@ -401,4 +417,34 @@ IF [Priority] = "high" THEN [Status] = "open" ENDIF`; expect(document.querySelector('[data-role="schema-textbox"]').value).toContain('Status\nenum(active,inactive)'); expect(document.querySelectorAll('.shared-schema-row')).toHaveLength(1); }); + + test('guided params dialog applies generated params back into the shared row editor', async () => { + const component = createComponent(); + + component.replaceRows([ + { + id: 'enum-row', + name: 'Status', + sourceType: 'domain', + command: 'datatype.enum', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }, + ]); + + fireEvent.click(document.querySelector('[data-action="edit-params"]')); + + const dialog = within(document.body).getByRole('dialog', { name: /edit params for datatype\.enum/i }); + const valuesInput = within(dialog).getByRole('textbox', { name: /values value/i }); + valuesInput.value = 'active,inactive,pending'; + fireEvent.input(valuesInput); + fireEvent.click(within(dialog).getByRole('button', { name: /^apply$/i })); + await Promise.resolve(); + await Promise.resolve(); + + expect(document.querySelector('[data-field="params"]').value).toBe('(active,inactive,pending)'); + expect(component.getSchemaText()).toContain('enum(active,inactive,pending)'); + }); }); diff --git a/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js b/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js index ac1a22ca..89ab4dd1 100644 --- a/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js +++ b/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js @@ -24,12 +24,14 @@ describe('createSharedSchemaEditorController', () => { dom = new JSDOM(''); global.document = dom.window.document; global.window = dom.window; + global.navigator = dom.window.navigator; }); afterEach(() => { dom.window.close(); delete global.document; delete global.window; + delete global.navigator; jest.restoreAllMocks(); }); @@ -96,4 +98,61 @@ describe('createSharedSchemaEditorController', () => { expect(timerApi.clearTimeout).toHaveBeenCalledWith('timer-1'); }); + + test('opens the params editor dialog and applies validated params back to the row', async () => { + const root = createRoot(dom.window.document); + const controller = createSharedSchemaEditorController({ + documentObj: dom.window.document, + rootElement: root, + createBlankRow: () => ({ + id: 'row-1', + name: 'Status', + sourceType: 'domain', + command: 'datatype.enum', + value: '', + params: '', + semanticValidationIssues: [], + }), + mapRuleToRow: () => ({ + id: 'row-1', + name: 'Status', + sourceType: 'domain', + command: 'datatype.enum', + value: '', + params: '', + semanticValidationIssues: [], + }), + schemaTextToDataRules: jest.fn(() => ({ dataRules: [], errors: [] })), + dataRulesToSchemaText: jest.fn(() => ''), + getMethodPickerOptions: () => [ + { + sourceType: 'domain', + command: 'datatype.enum', + helpModel: { + heading: 'datatype.enum', + summary: 'Enum helper', + params: [{ name: 'values', type: 'comma-separated list', optional: false }], + }, + }, + ], + getVisibleDomainCommands: () => ['datatype.enum'], + validateSchemaRows: jest.fn((rows) => ({ rows, errors: [] })), + updatePairwiseButtonVisibility: jest.fn(), + updateHelpHints: jest.fn(), + }); + + controller.init(); + const paramsButton = dom.window.document.querySelector('[data-action="edit-params"]'); + const dialogPromise = controller.handleClick({ target: paramsButton }); + + const dialog = dom.window.document.querySelector('[data-role="params-editor-dialog"]'); + expect(dialog).not.toBeNull(); + const paramsInput = dialog.querySelector('[data-role="params-editor-value"]'); + paramsInput.value = 'active,inactive,pending'; + paramsInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + dialog.querySelector('[data-role="params-editor-apply-button"]').click(); + + await dialogPromise; + expect(dom.window.document.querySelector('[data-field="params"]').value).toBe('(active,inactive,pending)'); + }); }); diff --git a/packages/core-ui/src/tests/shared/shared-schema-editor-ui.test.js b/packages/core-ui/src/tests/shared/shared-schema-editor-ui.test.js index 658be414..8747fc80 100644 --- a/packages/core-ui/src/tests/shared/shared-schema-editor-ui.test.js +++ b/packages/core-ui/src/tests/shared/shared-schema-editor-ui.test.js @@ -17,6 +17,8 @@ import { SHARED_SCHEMA_COMMAND_PICKER_SHADOW_SELECT_CLASS, SHARED_SCHEMA_COMMAND_ROW_CLASS, SHARED_SCHEMA_VALUE_ROW_CLASS, + SHARED_SCHEMA_PARAMS_CONTROL_CLASS, + SHARED_SCHEMA_PARAMS_BUTTON_CLASS, SCHEMA_ROW_DRAGGING_CLASS, SCHEMA_ROW_DROP_BEFORE_CLASS, SCHEMA_ROW_DROP_AFTER_CLASS, @@ -197,7 +199,7 @@ describe('shared schema editor ui', () => { rowsElement, schemaRows: [{ id: '1', name: 'First', sourceType: 'faker', command: 'person.firstName', params: '' }], fakerCommands: ['person.firstName'], - getSchemaHelpData: () => ({ show: false, docsUrl: '', title: '', html: '' }), + getSchemaHelpData: () => ({ show: false, docsUrl: '', title: '', html: '', params: [{ name: 'locale' }] }), updateAllPairsButtonVisibility: () => {}, }); @@ -206,6 +208,8 @@ describe('shared schema editor ui', () => { const commandPickerControl = document.querySelector(`.${SHARED_SCHEMA_COMMAND_PICKER_CONTROL_CLASS}`); const commandPickerButton = document.querySelector(`.${SHARED_SCHEMA_COMMAND_PICKER_BUTTON_CLASS}`); const commandPickerSelect = document.querySelector(`.${SHARED_SCHEMA_COMMAND_PICKER_SHADOW_SELECT_CLASS}`); + const paramsControl = document.querySelector(`.${SHARED_SCHEMA_PARAMS_CONTROL_CLASS}`); + const paramsButton = document.querySelector(`.${SHARED_SCHEMA_PARAMS_BUTTON_CLASS}`); expect(rowActions).not.toBeNull(); expect(document.querySelector('.generator-row-actions')).toBeNull(); @@ -217,6 +221,28 @@ describe('shared schema editor ui', () => { expect(document.querySelector('.generator-command-picker-button')).toBeNull(); expect(commandPickerSelect).not.toBeNull(); expect(document.querySelector('.generator-command-picker-shadow-select')).toBeNull(); + expect(paramsControl).not.toBeNull(); + expect(paramsButton).not.toBeNull(); + expect(paramsButton.disabled).toBe(false); + }); + + test('disables the params dialog button when no documented params exist', () => { + renderSharedSchemaRows({ + documentObj: document, + rowsElement, + schemaRows: [{ id: '1', name: 'First', sourceType: 'domain', command: 'person.firstName', params: '' }], + getVisibleDomainCommands: () => ['person.firstName'], + getSchemaHelpData: () => ({ + show: true, + docsUrl: '/docs/example', + title: 'Help', + html: '

Help

', + params: [], + }), + updateAllPairsButtonVisibility: () => {}, + }); + + expect(document.querySelector(`.${SHARED_SCHEMA_PARAMS_BUTTON_CLASS}`)?.disabled).toBe(true); }); test('renders row layout state with shared-only classes', () => { diff --git a/packages/core-ui/src/tests/utils/params-editor-modal.test.js b/packages/core-ui/src/tests/utils/params-editor-modal.test.js new file mode 100644 index 00000000..238b4557 --- /dev/null +++ b/packages/core-ui/src/tests/utils/params-editor-modal.test.js @@ -0,0 +1,147 @@ +import { JSDOM } from 'jsdom'; +import { fireEvent, within } from '@testing-library/dom'; +import { + splitTopLevelCommaSeparated, + parseInitialParamEntries, + buildParamsTextFromEditorEntries, + openParamsEditorModal, +} from '../../../js/gui_components/shared/test-data/ui/params-editor-modal.js'; + +describe('params editor modal', () => { + let dom; + let getOverlay; + + beforeEach(() => { + dom = new JSDOM('', { url: 'https://example.com' }); + global.document = dom.window.document; + global.window = dom.window; + global.navigator = dom.window.navigator; + getOverlay = () => document.querySelector('[data-role="params-editor-overlay"]'); + }); + + afterEach(() => { + dom.window.close(); + delete global.document; + delete global.window; + delete global.navigator; + }); + + test('splits top-level comma values while preserving nested arrays and quoted commas', () => { + expect(splitTopLevelCommaSeparated('"Ada, Lovelace",["Bob","Cara"],style=13')).toEqual({ + values: ['"Ada, Lovelace"', '["Bob","Cara"]', 'style=13'], + error: '', + }); + }); + + test('parses existing params into documented fields and infers editor modes', () => { + const parsed = parseInitialParamEntries({ + params: [ + { name: 'locale', type: 'string', optional: true }, + { name: 'list', type: 'array', optional: false }, + ], + initialParams: '("en-GB",["Ada","Bob"])', + }); + + expect(parsed.error).toBe(''); + expect(parsed.entries).toEqual([ + expect.objectContaining({ name: 'locale', value: 'en-GB', mode: 'text' }), + expect.objectContaining({ name: 'list', value: '["Ada","Bob"]', mode: 'raw' }), + ]); + }); + + test('builds params text using auto quoting and raw array preservation', () => { + const result = buildParamsTextFromEditorEntries({ + entries: [ + { name: 'locale', type: 'string', value: 'en-GB', mode: 'auto', optional: false }, + { name: 'items', type: 'array', value: '["Ada","Bob"]', mode: 'auto', optional: false }, + ], + }); + + expect(result).toEqual({ + paramsText: '("en-GB",["Ada","Bob"])', + errors: [], + }); + }); + + test('reports missing earlier params before later params are used', () => { + const result = buildParamsTextFromEditorEntries({ + entries: [ + { name: 'first', type: 'string', value: '', mode: 'auto', optional: false }, + { name: 'second', type: 'string', value: 'later', mode: 'auto', optional: true }, + ], + }); + + expect(result.paramsText).toBe('("later")'); + expect(result.errors).toEqual(['Param first must be filled before later params can be used.']); + }); + + test('surfaces semantic validation errors from the injected validator', () => { + const result = buildParamsTextFromEditorEntries({ + entries: [{ name: 'style', type: 'string', value: 'style=13', mode: 'raw', optional: false }], + validateParams: () => ['Row 1: invalid domain params - unsupported option.'], + }); + + expect(result).toEqual({ + paramsText: '(style=13)', + errors: ['Row 1: invalid domain params - unsupported option.'], + }); + }); + + test('disables apply and shows validation until a required value is entered', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'datatype.enum', + helpModel: { + summary: 'Enum helper', + params: [{ name: 'values', type: 'comma-separated list', optional: false, example: 'active,inactive' }], + }, + initialParams: '', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for datatype\.enum/i }); + const applyButton = within(dialog).getByRole('button', { name: /^apply$/i }); + const error = dialog.querySelector('[data-role="params-editor-error"]'); + const input = within(dialog).getByRole('textbox', { name: /values value/i }); + const preview = within(dialog).getByText('()', { selector: '[data-role="params-editor-preview"]' }); + + expect(applyButton.disabled).toBe(true); + expect(error.textContent).toContain('required'); + expect(preview.textContent).toBe('()'); + + input.value = 'active,inactive,pending'; + fireEvent.input(input); + + expect(applyButton.disabled).toBe(false); + expect(error.textContent).toBe(''); + expect( + within(dialog).getByText('(active,inactive,pending)', { selector: '[data-role="params-editor-preview"]' }) + ).toBeTruthy(); + + fireEvent.click(applyButton); + await expect(promise).resolves.toBe('(active,inactive,pending)'); + }); + + test('shows a warning when existing params cannot be mapped to the documented fields', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'food.ingredient', + helpModel: { + summary: 'Ingredient label', + params: [{ name: 'locale', type: 'string', optional: true }], + }, + initialParams: '("en","extra")', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for food\.ingredient/i }); + const warning = dialog.querySelector('[data-role="params-editor-warning"]'); + const applyButton = within(dialog).getByRole('button', { name: /^apply$/i }); + + expect(warning.textContent).toContain('documented fields'); + expect(applyButton.disabled).toBe(true); + + fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); + await expect(promise).resolves.toBeNull(); + }); +}); From 4e90d3929f620093302184c085c88dce7b51b0c7 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 10:56:39 +0100 Subject: [PATCH 02/10] Fix enum params dialog for variadic values --- .../shared/domain-command-help-metadata.js | 1 + .../test-data/ui/params-editor-modal.js | 7 ++-- .../tests/utils/params-editor-modal.test.js | 35 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js b/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js index 7a1c8f53..a97b6f8b 100644 --- a/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js +++ b/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js @@ -16,6 +16,7 @@ const SYNTHETIC_DOMAIN_HELP = Object.freeze({ { name: 'values', type: 'comma-separated list', + variadic: true, optional: false, description: 'List of allowed enum values chosen at random during generation.', example: 'active,inactive,pending', diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js index c1e58a2a..98cf5ea0 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js @@ -134,6 +134,8 @@ function inferEditorMode(rawValue, paramType = '') { function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { const metadata = Array.isArray(params) ? params : []; + const variadicIndex = metadata.findIndex((param) => param?.variadic === true); + const hasVariadicTail = variadicIndex >= 0 && variadicIndex === metadata.length - 1; const trimmed = stripOuterParens(initialParams); if (!trimmed) { return { @@ -153,7 +155,7 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { if (split.error) { return { entries: [], error: split.error }; } - if (split.values.length > metadata.length) { + if (split.values.length > metadata.length && !hasVariadicTail) { return { entries: [], error: @@ -163,7 +165,8 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { return { entries: metadata.map((param, index) => { - const rawValue = split.values[index] || ''; + const rawValue = + hasVariadicTail && index === variadicIndex ? split.values.slice(index).join(',') : split.values[index] || ''; return { name: param?.name || '', type: param?.type || '', diff --git a/packages/core-ui/src/tests/utils/params-editor-modal.test.js b/packages/core-ui/src/tests/utils/params-editor-modal.test.js index 238b4557..9f5ac899 100644 --- a/packages/core-ui/src/tests/utils/params-editor-modal.test.js +++ b/packages/core-ui/src/tests/utils/params-editor-modal.test.js @@ -49,6 +49,18 @@ describe('params editor modal', () => { ]); }); + test('parses variadic documented params as a single editable list value', () => { + const parsed = parseInitialParamEntries({ + params: [{ name: 'values', type: 'comma-separated list', optional: false, variadic: true }], + initialParams: '(active,inactive,pending)', + }); + + expect(parsed.error).toBe(''); + expect(parsed.entries).toEqual([ + expect.objectContaining({ name: 'values', value: 'active,inactive,pending', mode: 'raw' }), + ]); + }); + test('builds params text using auto quoting and raw array preservation', () => { const result = buildParamsTextFromEditorEntries({ entries: [ @@ -144,4 +156,27 @@ describe('params editor modal', () => { fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); await expect(promise).resolves.toBeNull(); }); + + test('opens enum params with an existing unbounded list without showing an overflow warning', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'datatype.enum', + helpModel: { + summary: 'Enum helper', + params: [{ name: 'values', type: 'comma-separated list', optional: false, variadic: true }], + }, + initialParams: '(active,inactive,pending)', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for datatype\.enum/i }); + const warning = dialog.querySelector('[data-role="params-editor-warning"]'); + const input = within(dialog).getByRole('textbox', { name: /values value/i }); + + expect(warning).toBeNull(); + expect(input.value).toBe('active,inactive,pending'); + + fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); + await expect(promise).resolves.toBeNull(); + }); }); From f76fab598c6cb3174994db2f8905ba8848d05621 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 11:09:25 +0100 Subject: [PATCH 03/10] Improve Storybook params dialog framing --- apps/web/src/stories/shared-schema-definition.stories.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/stories/shared-schema-definition.stories.js b/apps/web/src/stories/shared-schema-definition.stories.js index 9e61673b..2eb2abe4 100644 --- a/apps/web/src/stories/shared-schema-definition.stories.js +++ b/apps/web/src/stories/shared-schema-definition.stories.js @@ -87,6 +87,7 @@ function renderSharedSchemaDefinitionStory(args) { const fakerCommands = getFakerCommands().filter((command) => command !== 'RegEx' && command.startsWith('helpers.')); const domainCommands = getDomainCommands(); const root = document.createElement('section'); + root.style.minHeight = args.storyMinHeight || 'auto'; const ids = createIds(args.idPrefix || 'shared-schema'); const createBlankRow = createBlankRowFactory(args.idPrefix || 'story-schema-row'); const component = createSharedSchemaDefinitionComponent({ @@ -201,6 +202,7 @@ const meta = { initialText: '', showErrors: false, startInTextMode: false, + storyMinHeight: 'auto', }, }; @@ -427,6 +429,7 @@ export const CommandPicker = { export const ParamsDialog = { render: renderSharedSchemaDefinitionStory, args: { + storyMinHeight: '820px', initialRows: [ { id: 'enum-row', @@ -441,10 +444,11 @@ export const ParamsDialog = { ], }, parameters: { + layout: 'fullscreen', docs: { description: { story: - 'Guided params editing flow for documented command params. Reviewers can open the params dialog, fill values through the structured table, and confirm the generated schema params text is applied back to the shared row editor.', + 'Guided params editing flow for documented command params. This story reserves a taller canvas so the modal stays inside the visible Storybook frame while reviewers open the dialog, fill values through the structured table, and confirm the generated schema params text is applied back to the shared row editor.', }, }, }, From 9d76f7a51135965cb08fa6dd340a3dd63527fe1d Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 11:17:29 +0100 Subject: [PATCH 04/10] Prevent command picker style flash --- .../test-data/ui/method-picker-modal.js | 71 +++++++++++++++++++ .../tests/utils/method-picker-modal.test.js | 17 +++++ 2 files changed, 88 insertions(+) diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js b/packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js index c188d881..c4c2b6aa 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js @@ -3,6 +3,7 @@ import { getDefaultDocumentObj, getDefaultWindowObj, resolveWindowObj } from '.. const RECENT_STORAGE_KEY = 'anywaydata.method-picker.recent'; const MAX_RECENT = 8; const STYLE_ID = 'method-picker-modal-styles-link'; +const CRITICAL_STYLE_ID = 'method-picker-modal-critical-styles'; const CORE_COMMANDS = new Set(['enum', 'literal', 'regex']); function escapeHtml(text) { @@ -57,6 +58,75 @@ function toExampleList(value) { return single ? [single] : []; } +function ensureCriticalStyles(documentObj) { + if (!documentObj?.head || documentObj.getElementById(CRITICAL_STYLE_ID)) { + return; + } + const style = documentObj.createElement('style'); + style.id = CRITICAL_STYLE_ID; + style.textContent = ` + .method-picker-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 6000; + background: rgba(15, 23, 42, 0.45); + } + + .method-picker-modal { + width: min(1200px, 100%); + max-height: calc(100vh - 40px); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 12px; + background: #ffffff; + color: #121620; + border: 1px solid #d6dce8; + } + + .method-picker-header, + .method-picker-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + } + + .method-picker-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr); + gap: 10px; + padding: 12px 16px; + } + + .method-picker-content { + display: grid; + grid-template-columns: minmax(260px, 1.1fr) minmax(280px, 1fr); + flex: 1; + min-height: 0; + } + + .method-picker-list, + .method-picker-detail { + min-height: 0; + overflow: auto; + padding: 12px; + } + + @media (max-width: 980px) { + .method-picker-content { + grid-template-columns: 1fr; + } + } + `; + documentObj.head.appendChild(style); +} + function ensureStyles(documentObj) { if (!documentObj?.head || documentObj.getElementById(STYLE_ID)) { return; @@ -129,6 +199,7 @@ function openMethodPickerModal({ return Promise.resolve(null); } windowObj = resolveWindowObj(windowObj, documentObj); + ensureCriticalStyles(documentObj); ensureStyles(documentObj); const overlay = documentObj.createElement('div'); overlay.className = 'method-picker-overlay'; diff --git a/packages/core-ui/src/tests/utils/method-picker-modal.test.js b/packages/core-ui/src/tests/utils/method-picker-modal.test.js index 2a850801..70aa46f3 100644 --- a/packages/core-ui/src/tests/utils/method-picker-modal.test.js +++ b/packages/core-ui/src/tests/utils/method-picker-modal.test.js @@ -327,4 +327,21 @@ describe('method picker modal', () => { global.window = originalWindow; } }); + + test('injects critical inline layout styles before the external stylesheet loads', async () => { + const promise = openMethodPickerModal({ + documentObj: document, + windowObj: window, + options: [{ sourceType: 'domain', command: 'number.int', helpModel: { summary: '', params: [], example: '' } }], + currentCommand: 'number.int', + }); + + const criticalStyle = document.getElementById('method-picker-modal-critical-styles'); + expect(criticalStyle).not.toBeNull(); + expect(criticalStyle.textContent).toContain('.method-picker-overlay'); + expect(criticalStyle.textContent).toContain('.method-picker-content'); + + getOverlay().querySelector('[data-role="method-picker-cancel-button"]').click(); + await promise; + }); }); From 223351eec792dda7b0ef824a6063aaa4b5b6c906 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 11:24:17 +0100 Subject: [PATCH 05/10] Use edit icon for params dialog --- .../shared/test-data/schema/shared-schema-editor-ui.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js index d3dfd546..9013dfda 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js +++ b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-ui.js @@ -144,7 +144,7 @@ function renderSharedSchemaRows({ aria-label="Edit params for ${escapeHtml(row.command || 'selected command')}" title="${hasDocumentedParams ? 'Edit params in dialog' : 'No documented params available'}" ${hasDocumentedParams ? '' : 'disabled'} - >${renderIconHtml('settings')} + >${renderIconHtml('pencil')} ` : `` } From ca83fdd419e2b46433f993ab95699fbb1bab682c Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 11:53:39 +0100 Subject: [PATCH 06/10] Refine guided params editor defaults --- .../shared-schema-definition.stories.js | 21 +++-- docs/frontend-component-migration-plan.md | 1 + .../test-data/help/help-model-builder.js | 66 +++++++++++++-- .../test-data/ui/params-editor-modal.css | 29 +++++-- .../test-data/ui/params-editor-modal.js | 50 ++++++++--- .../tests/shared/help-model-builder.test.js | 16 ++++ .../tests/utils/params-editor-modal.test.js | 84 +++++++++++++++++++ 7 files changed, 233 insertions(+), 34 deletions(-) diff --git a/apps/web/src/stories/shared-schema-definition.stories.js b/apps/web/src/stories/shared-schema-definition.stories.js index 2eb2abe4..0203a424 100644 --- a/apps/web/src/stories/shared-schema-definition.stories.js +++ b/apps/web/src/stories/shared-schema-definition.stories.js @@ -432,10 +432,10 @@ export const ParamsDialog = { storyMinHeight: '820px', initialRows: [ { - id: 'enum-row', - name: 'Status', + id: 'sequence-row', + name: 'SequenceId', sourceType: 'domain', - command: 'datatype.enum', + command: 'autoIncrement.sequence', params: '', value: '', comments: '', @@ -448,7 +448,7 @@ export const ParamsDialog = { docs: { description: { story: - 'Guided params editing flow for documented command params. This story reserves a taller canvas so the modal stays inside the visible Storybook frame while reviewers open the dialog, fill values through the structured table, and confirm the generated schema params text is applied back to the shared row editor.', + 'Guided params editing flow for documented command params. This story demonstrates the value-only editor: required state appears as read-only checkboxes, documented defaults are prefilled into the value inputs, and string values are auto-quoted when the generated schema params text is built. The taller canvas keeps the modal inside the visible Storybook frame while reviewers inspect the defaults and apply updated values back into the shared row editor.', }, }, }, @@ -459,16 +459,19 @@ export const ParamsDialog = { expect(paramsButton).not.toBeNull(); await userEvent.click(paramsButton); - const dialog = within(document.body).getByRole('dialog', { name: /edit params for .*datatype\.enum/i }); + const dialog = within(document.body).getByRole('dialog', { name: /edit params for .*autoincrement\.sequence/i }); const dialogScope = within(dialog); - const valuesInput = dialogScope.getByRole('textbox', { name: /values value/i }); - await userEvent.type(valuesInput, 'active,inactive,pending'); - await expect(dialogScope.getByText('(active,inactive,pending)')).toBeTruthy(); + expect(dialog.querySelector('[data-role="params-editor-mode"]')).toBeNull(); + await expect(dialogScope.getByRole('textbox', { name: /start value/i }).value).toBe('1'); + await expect(dialogScope.getByRole('textbox', { name: /step value/i }).value).toBe('1'); + const prefixInput = dialogScope.getByRole('textbox', { name: /prefix value/i }); + await userEvent.type(prefixInput, 'filename'); + await expect(dialogScope.getByText('(start=1,step=1,prefix="filename",zeropadding=0)')).toBeTruthy(); await userEvent.click(dialogScope.getByRole('button', { name: /^apply$/i })); await waitFor(() => expect(canvasElement.querySelector('.shared-schema-row [data-field="params"]').value).toBe( - '(active,inactive,pending)' + '(start=1,step=1,prefix="filename",zeropadding=0)' ) ); }, diff --git a/docs/frontend-component-migration-plan.md b/docs/frontend-component-migration-plan.md index f7498169..60615931 100644 --- a/docs/frontend-component-migration-plan.md +++ b/docs/frontend-component-migration-plan.md @@ -306,6 +306,7 @@ Current status: - `SharedSchemaDefinition` now lives under `shared/schema-definition/` with a controller, view, and create-component factory. - The new component reuses the existing shared schema parsing, validation, row-editing, command-picker, drag/drop, and text-mode logic from `shared/test-data/schema/` instead of duplicating those rules. - The shared schema row editor now also exposes a documented params-edit dialog for commands with parameter metadata, so app and generator hosts share the same guided param-entry flow and semantic validation path instead of leaving params as raw punctuation-only text entry. +- The shared params-edit dialog now normalizes command metadata so optional/defaulted params stay visibly optional, shows reviewer-facing `Req` checkboxes instead of text labels, and auto-quotes string values while keeping the dialog focused on value entry rather than quote-format selection. - The app test-data panel now mounts its schema editor through `SharedSchemaDefinition`, keeping the same DOM IDs so the rest of the app flow and browser tests continue to treat the schema surface as a black box. - The generator page now also mounts its live schema editor through `SharedSchemaDefinition`, while preserving the page-level `#generatorSchemaSection` host contract, row-level browser interactions, and text-mode generate/pairwise flows. - Generator runtime adoption also moved the shared text-mode syncing, method-picker command selection, semantic-validation caret preservation, and pairwise-button visibility behavior onto the shared component path. diff --git a/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js b/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js index 047ed91c..deac2124 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js +++ b/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js @@ -27,6 +27,15 @@ const HELP_URLS = Object.freeze({ enum: 'https://anywaydata.com/docs/category/generating-data', }); +const ENUM_VALUE_PARAM = Object.freeze({ + name: 'values', + type: 'comma-separated list', + variadic: true, + optional: false, + description: 'List of allowed values chosen at random during generation.', + example: 'active,inactive,pending', +}); + function resolveFakerDocsUrl(command, docsUrl) { const normalizedCommand = String(command || '').trim(); if (normalizedCommand.startsWith('helpers.')) { @@ -42,6 +51,54 @@ function cleanParamText(text) { .trim(); } +function extractSimpleDefaultValue(param = {}) { + if (Object.prototype.hasOwnProperty.call(param, 'defaultValue')) { + return param.defaultValue; + } + if (Object.prototype.hasOwnProperty.call(param, 'default')) { + return param.default; + } + + const description = cleanParamText(param.description); + const match = description.match(/defaults?\s+to\s+("[^"]*"|'[^']*'|-?\d+(?:\.\d+)?|true|false|null)\.?$/iu); + if (!match) { + return ''; + } + + const value = match[1]; + if (value.startsWith('"') || value.startsWith("'")) { + return value.slice(1, -1); + } + return value; +} + +function normalizeHelpParam(param = {}) { + return { + ...param, + optional: param.optional === true || param.required === false, + defaultValue: extractSimpleDefaultValue(param), + }; +} + +function normalizeHelpParams(params = []) { + return (Array.isArray(params) ? params : []).map((param) => normalizeHelpParam(param)); +} + +function resolveDomainHelpParams(command, commandHelp) { + const normalized = normalizeHelpParams(commandHelp?.args || []); + if (normalized.length > 0) { + return normalized; + } + if ( + String(command || '') + .trim() + .toLowerCase() === 'datatype.enum' + ) { + return normalizeHelpParams([ENUM_VALUE_PARAM]); + } + return []; +} + function buildCallSignature(heading, params) { if (!Array.isArray(params) || params.length === 0) { return `${heading}()`; @@ -169,11 +226,8 @@ function buildSchemaHelpModel(sourceType, commandValue) { { params: [ { - name: 'values', - type: 'comma-separated list', - optional: false, + ...ENUM_VALUE_PARAM, description: 'List of allowed values randomly selected during generation.', - example: 'active,inactive,pending', }, ], example: 'enum active,inactive,pending', @@ -198,7 +252,7 @@ function buildSchemaHelpModel(sourceType, commandValue) { heading: `faker.${command}`, summary: commandHelp?.summary || `Generates data using faker.${command}.`, docsUrl: resolveFakerDocsUrl(command, commandHelp?.docsUrl), - params: commandHelp?.params || [], + params: normalizeHelpParams(commandHelp?.params || []), example: commandHelp?.example || '', examples: Array.isArray(commandHelp?.examples) ? commandHelp.examples : [], exampleReturnValues: Array.isArray(commandHelp?.exampleReturnValues) @@ -226,7 +280,7 @@ function buildSchemaHelpModel(sourceType, commandValue) { heading: commandHelp?.canonical || command, summary: commandHelp?.summary || `Generates data using ${commandHelp?.canonical || command}.`, docsUrl: commandHelp?.docsUrl || HELP_URLS.domain, - params: commandHelp?.args || [], + params: resolveDomainHelpParams(command, commandHelp), example: commandHelp?.example || '', examples: Array.isArray(commandHelp?.examples) ? commandHelp.examples : [], exampleReturnValues: Array.isArray(commandHelp?.exampleReturnValues) diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css index 65cdf670..71fcd4b5 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css @@ -81,8 +81,7 @@ body.theme-dark { .params-editor-close, .params-editor-footer button, -.params-editor-table input, -.params-editor-table select { +.params-editor-table input { border: 1px solid var(--pe-border, #ddd); border-radius: 8px; background: var(--pe-bg, #fff); @@ -155,12 +154,31 @@ body.theme-dark { border-bottom: 0; } -.params-editor-table input, -.params-editor-table select { +.params-editor-table input { width: 100%; padding: 8px 10px; } +.params-editor-required-checkbox-label { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; +} + +.params-editor-required-checkbox-label input[type='checkbox'] { + width: 18px; + height: 18px; + accent-color: var(--pe-accent, #3366ff); + cursor: default; +} + +.params-editor-default-hint { + margin: 6px 0 0; + color: var(--pe-muted, #555); + font-size: 12px; +} + .params-editor-preview-block { margin-top: 14px; } @@ -190,8 +208,7 @@ body.theme-dark { } .params-editor-modal button:focus-visible, -.params-editor-modal input:focus-visible, -.params-editor-modal select:focus-visible { +.params-editor-modal input:focus-visible { outline: 2px solid var(--pe-focus, #4a80ff); outline-offset: 2px; } diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js index 98cf5ea0..6d60694e 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js @@ -143,8 +143,10 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { name: param?.name || '', type: param?.type || '', optional: param?.optional === true, + variadic: param?.variadic === true, example: param?.example || '', - value: '', + defaultValue: String(param?.defaultValue ?? ''), + value: String(param?.defaultValue ?? ''), mode: 'auto', })), error: '', @@ -171,7 +173,9 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { name: param?.name || '', type: param?.type || '', optional: param?.optional === true, + variadic: param?.variadic === true, example: param?.example || '', + defaultValue: String(param?.defaultValue ?? ''), value: rawValue ? unquoteValue(rawValue) : '', mode: inferEditorMode(rawValue, param?.type || ''), }; @@ -200,7 +204,7 @@ function formatEditorValue(value, mode, paramType = '') { if (resolvedMode === 'raw') { return rawValue.trim(); } - return JSON.stringify(rawValue); + return JSON.stringify(unquoteValue(rawValue)); } function buildParamsTextFromEditorEntries({ entries = [], validateParams = null } = {}) { @@ -222,12 +226,25 @@ function buildParamsTextFromEditorEntries({ entries = [], validateParams = null }; } + const shouldUseNamedParams = normalizedEntries.slice(0, lastFilledIndex + 1).some((entry, index) => { + const trimmedValue = String(entry?.value ?? '').trim(); + if (trimmedValue || entry?.optional !== true) { + return false; + } + return normalizedEntries + .slice(index + 1, lastFilledIndex + 1) + .some((laterEntry) => String(laterEntry?.value ?? '').trim().length > 0); + }); + const formattedValues = []; for (let index = 0; index <= lastFilledIndex; index += 1) { const entry = normalizedEntries[index] || {}; const rawValue = String(entry.value ?? ''); const trimmedValue = rawValue.trim(); if (!trimmedValue) { + if (entry.optional === true && shouldUseNamedParams) { + continue; + } errors.push(`Param ${entry.name || index + 1} must be filled before later params can be used.`); continue; } @@ -238,7 +255,10 @@ function buildParamsTextFromEditorEntries({ entries = [], validateParams = null continue; } } - formattedValues.push(formatEditorValue(rawValue, entry.mode || 'auto', entry.type || '')); + const formattedValue = formatEditorValue(rawValue, entry.mode || 'auto', entry.type || ''); + formattedValues.push( + shouldUseNamedParams && entry.name && entry.variadic !== true ? `${entry.name}=${formattedValue}` : formattedValue + ); } const paramsText = formattedValues.length > 0 ? `(${formattedValues.join(',')})` : ''; @@ -262,13 +282,16 @@ function renderEntryRows(entries = []) { ${escapeHtml(entry.type || 'unknown')} - ${entry.optional ? 'Optional' : 'Required'} - + + ${ + entry.defaultValue + ? `

Default: ${escapeHtml(entry.defaultValue)}

` + : '' + } ` @@ -331,7 +359,6 @@ function openParamsEditorModal({ Name Type Req - Format Value @@ -356,7 +383,6 @@ function openParamsEditorModal({ const errorElement = overlay.querySelector('[data-role="params-editor-error"]'); const applyButton = overlay.querySelector('[data-role="params-editor-apply-button"]'); const valueInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-value"]')); - const modeInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-mode"]')); const warningVisible = Boolean(parsed.error); let currentEntries = parsed.entries.map((entry) => ({ ...entry })); @@ -372,7 +398,6 @@ function openParamsEditorModal({ currentEntries = currentEntries.map((entry, index) => ({ ...entry, value: valueInputs()[index]?.value ?? entry.value, - mode: modeInputs()[index]?.value ?? entry.mode, })); const result = buildParamsTextFromEditorEntries({ entries: currentEntries, @@ -428,7 +453,6 @@ function openParamsEditorModal({ }); valueInputs().forEach((input) => input.addEventListener('input', syncPreview)); - modeInputs().forEach((select) => select.addEventListener('change', syncPreview)); documentObj.body.appendChild(overlay); syncPreview(); diff --git a/packages/core-ui/src/tests/shared/help-model-builder.test.js b/packages/core-ui/src/tests/shared/help-model-builder.test.js index d2d16599..49972266 100644 --- a/packages/core-ui/src/tests/shared/help-model-builder.test.js +++ b/packages/core-ui/src/tests/shared/help-model-builder.test.js @@ -80,10 +80,26 @@ describe('help-model-builder', () => { expect(model.docsUrl).toBe('https://anywaydata.com/docs/test-data/auto-increment-sequences'); expect(model.params.map((param) => param.name)).toEqual(['start', 'step', 'prefix', 'suffix', 'zeropadding']); + expect(model.params.map((param) => param.optional)).toEqual([true, true, true, true, true]); + expect(model.params.map((param) => param.defaultValue)).toEqual(['1', '1', '', '', '0']); expect(renderSchemaHelpHtml(model)).toContain('accepted row'); expect(renderSchemaHelpHtml(model)).toContain('zeropadding'); }); + test('keeps documented params for datatype.enum domain help', () => { + const model = buildSchemaHelpModel('domain', 'datatype.enum'); + + expect(model.params).toEqual([ + expect.objectContaining({ + name: 'values', + variadic: true, + optional: false, + }), + ]); + expect(renderSchemaHelpHtml(model)).toContain('Schema params field'); + expect(renderSchemaHelpHtml(model)).toContain('values'); + }); + test('maps faker helpers docs link to anywaydata faker helpers docs', () => { const model = buildSchemaHelpModel('faker', 'helpers.fake'); expect(model.docsUrl).toBe('https://anywaydata.com/docs/test-data/faker/helpers'); diff --git a/packages/core-ui/src/tests/utils/params-editor-modal.test.js b/packages/core-ui/src/tests/utils/params-editor-modal.test.js index 9f5ac899..e9f29a67 100644 --- a/packages/core-ui/src/tests/utils/params-editor-modal.test.js +++ b/packages/core-ui/src/tests/utils/params-editor-modal.test.js @@ -49,6 +49,24 @@ describe('params editor modal', () => { ]); }); + test('prefills explicit default values when there are no existing params', () => { + const parsed = parseInitialParamEntries({ + params: [ + { name: 'start', type: 'integer', optional: true, defaultValue: '1' }, + { name: 'step', type: 'integer', optional: true, defaultValue: '1' }, + { name: 'zeropadding', type: 'integer', optional: true, defaultValue: '0' }, + ], + initialParams: '', + }); + + expect(parsed.error).toBe(''); + expect(parsed.entries).toEqual([ + expect.objectContaining({ name: 'start', value: '1', defaultValue: '1' }), + expect.objectContaining({ name: 'step', value: '1', defaultValue: '1' }), + expect.objectContaining({ name: 'zeropadding', value: '0', defaultValue: '0' }), + ]); + }); + test('parses variadic documented params as a single editable list value', () => { const parsed = parseInitialParamEntries({ params: [{ name: 'values', type: 'comma-separated list', optional: false, variadic: true }], @@ -99,6 +117,34 @@ describe('params editor modal', () => { }); }); + test('auto quotes string values even when the editor input includes surrounding quotes', () => { + const result = buildParamsTextFromEditorEntries({ + entries: [{ name: 'prefix', type: 'string', value: '"filename"', mode: 'text', optional: true }], + }); + + expect(result).toEqual({ + paramsText: '("filename")', + errors: [], + }); + }); + + test('switches to named params when later values skip optional gaps', () => { + const result = buildParamsTextFromEditorEntries({ + entries: [ + { name: 'start', type: 'integer', value: '1', mode: 'auto', optional: true }, + { name: 'step', type: 'integer', value: '1', mode: 'auto', optional: true }, + { name: 'prefix', type: 'string', value: 'filename', mode: 'auto', optional: true }, + { name: 'suffix', type: 'string', value: '', mode: 'auto', optional: true }, + { name: 'zeropadding', type: 'integer', value: '0', mode: 'auto', optional: true }, + ], + }); + + expect(result).toEqual({ + paramsText: '(start=1,step=1,prefix="filename",zeropadding=0)', + errors: [], + }); + }); + test('disables apply and shows validation until a required value is entered', async () => { const promise = openParamsEditorModal({ documentObj: document, @@ -179,4 +225,42 @@ describe('params editor modal', () => { fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); await expect(promise).resolves.toBeNull(); }); + + test('renders checkbox-based req state and omits the format selector for auto increment defaults', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'autoIncrement.sequence', + helpModel: { + summary: 'Sequence helper', + params: [ + { name: 'start', type: 'integer', optional: true, defaultValue: '1' }, + { name: 'step', type: 'integer', optional: true, defaultValue: '1' }, + { name: 'prefix', type: 'string', optional: true, defaultValue: '' }, + { name: 'suffix', type: 'string', optional: true, defaultValue: '' }, + { name: 'zeropadding', type: 'integer', optional: true, defaultValue: '0' }, + ], + }, + initialParams: '', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for autoincrement\.sequence/i }); + const headerCells = Array.from(dialog.querySelectorAll('th')).map((cell) => cell.textContent.trim()); + expect(headerCells).toEqual(['Name', 'Type', 'Req', 'Value']); + expect(dialog.querySelector('[data-role="params-editor-mode"]')).toBeNull(); + + const reqBoxes = Array.from(dialog.querySelectorAll('[data-role="params-editor-required"]')); + expect(reqBoxes).toHaveLength(5); + expect(reqBoxes.every((box) => box.checked === false)).toBe(true); + + const startInput = within(dialog).getByRole('textbox', { name: /start value/i }); + const zeroPaddingInput = within(dialog).getByRole('textbox', { name: /zeropadding value/i }); + expect(startInput.value).toBe('1'); + expect(zeroPaddingInput.value).toBe('0'); + expect(dialog.textContent).toContain('Default: 1'); + expect(dialog.textContent).toContain('Default: 0'); + + fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); + await expect(promise).resolves.toBeNull(); + }); }); From 0ca3d6c91b4ff8650ea6a76b7a4bacde28d8ca3b Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 12:10:53 +0100 Subject: [PATCH 07/10] Fix params dialog value field overflow --- .../shared/test-data/ui/params-editor-modal.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css index 71fcd4b5..0bbbc0e7 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css @@ -155,8 +155,12 @@ body.theme-dark { } .params-editor-table input { + display: block; width: 100%; + max-width: 100%; + min-width: 0; padding: 8px 10px; + box-sizing: border-box; } .params-editor-required-checkbox-label { From 4eb4f8f45d7333df27c91d3b3f6b1bfc9b6dc41a Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sun, 14 Jun 2026 12:26:47 +0100 Subject: [PATCH 08/10] Add param metadata tooltips --- .../shared-schema-definition.stories.js | 6 +- .../test-data/ui/params-editor-modal.css | 10 ++ .../test-data/ui/params-editor-modal.js | 126 +++++++++++++++++- .../tests/utils/params-editor-modal.test.js | 52 ++++++++ 4 files changed, 188 insertions(+), 6 deletions(-) diff --git a/apps/web/src/stories/shared-schema-definition.stories.js b/apps/web/src/stories/shared-schema-definition.stories.js index 0203a424..760328e1 100644 --- a/apps/web/src/stories/shared-schema-definition.stories.js +++ b/apps/web/src/stories/shared-schema-definition.stories.js @@ -448,7 +448,7 @@ export const ParamsDialog = { docs: { description: { story: - 'Guided params editing flow for documented command params. This story demonstrates the value-only editor: required state appears as read-only checkboxes, documented defaults are prefilled into the value inputs, and string values are auto-quoted when the generated schema params text is built. The taller canvas keeps the modal inside the visible Storybook frame while reviewers inspect the defaults and apply updated values back into the shared row editor.', + 'Guided params editing flow for documented command params. This story demonstrates the value-only editor: required state appears as read-only checkboxes, documented defaults are prefilled into the value inputs, each param row exposes a help tippy with descriptions/examples/rules, and string values are auto-quoted when the generated schema params text is built. Hover a row help icon to review the metadata before editing and applying the params back into the shared row editor.', }, }, }, @@ -461,7 +461,11 @@ export const ParamsDialog = { const dialog = within(document.body).getByRole('dialog', { name: /edit params for .*autoincrement\.sequence/i }); const dialogScope = within(dialog); + const firstHelpIcon = dialog.querySelector('[data-role="params-editor-param-help"]'); expect(dialog.querySelector('[data-role="params-editor-mode"]')).toBeNull(); + await expect(firstHelpIcon).toHaveAttribute('data-help-text', expect.stringContaining('Rules:')); + await expect(firstHelpIcon).toHaveAttribute('data-help-text', expect.stringContaining('Optional.')); + await expect(firstHelpIcon).toHaveAttribute('data-help-text', expect.stringContaining('Default: 1')); await expect(dialogScope.getByRole('textbox', { name: /start value/i }).value).toBe('1'); await expect(dialogScope.getByRole('textbox', { name: /step value/i }).value).toBe('1'); const prefixInput = dialogScope.getByRole('textbox', { name: /prefix value/i }); diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css index 0bbbc0e7..7d097925 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css @@ -154,6 +154,16 @@ body.theme-dark { border-bottom: 0; } +.params-editor-name-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.params-editor-help-icon { + flex: 0 0 auto; +} + .params-editor-table input { display: block; width: 100%; diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js index 6d60694e..358366e2 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js @@ -1,3 +1,4 @@ +import { createHelpTooltipService } from '../../../../help/help-tooltips.js'; import { getDefaultDocumentObj, getDefaultWindowObj, resolveWindowObj } from '../../dom/default-objects.js'; const STYLE_ID = 'params-editor-modal-styles-link'; @@ -30,6 +31,14 @@ function stripOuterParens(text) { return value; } +function toExampleList(value) { + if (Array.isArray(value)) { + return value.map((entry) => String(entry || '').trim()).filter(Boolean); + } + const single = String(value || '').trim(); + return single ? [single] : []; +} + function splitTopLevelCommaSeparated(text) { const value = String(text ?? ''); const items = []; @@ -144,8 +153,19 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { type: param?.type || '', optional: param?.optional === true, variadic: param?.variadic === true, + description: param?.description || '', example: param?.example || '', + examples: Array.isArray(param?.examples) ? param.examples : [], defaultValue: String(param?.defaultValue ?? ''), + min: param?.min, + minimum: param?.minimum, + max: param?.max, + maximum: param?.maximum, + pattern: param?.pattern, + multipleOf: param?.multipleOf, + allowedValues: Array.isArray(param?.allowedValues) ? param.allowedValues : [], + choices: Array.isArray(param?.choices) ? param.choices : [], + enumValues: Array.isArray(param?.enumValues) ? param.enumValues : [], value: String(param?.defaultValue ?? ''), mode: 'auto', })), @@ -174,8 +194,19 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { type: param?.type || '', optional: param?.optional === true, variadic: param?.variadic === true, + description: param?.description || '', example: param?.example || '', + examples: Array.isArray(param?.examples) ? param.examples : [], defaultValue: String(param?.defaultValue ?? ''), + min: param?.min, + minimum: param?.minimum, + max: param?.max, + maximum: param?.maximum, + pattern: param?.pattern, + multipleOf: param?.multipleOf, + allowedValues: Array.isArray(param?.allowedValues) ? param.allowedValues : [], + choices: Array.isArray(param?.choices) ? param.choices : [], + enumValues: Array.isArray(param?.enumValues) ? param.enumValues : [], value: rawValue ? unquoteValue(rawValue) : '', mode: inferEditorMode(rawValue, param?.type || ''), }; @@ -275,12 +306,87 @@ function buildParamsTextFromEditorEntries({ entries = [], validateParams = null }; } +function buildParamValidationRules(entry = {}) { + const rules = [entry.optional ? 'Optional.' : 'Required.']; + const defaultValue = String(entry.defaultValue ?? '').trim(); + if (defaultValue) { + rules.push(`Default: ${defaultValue}`); + } + if (entry.variadic) { + rules.push('Accepts multiple values in this one field.'); + } + + const numericRules = [ + ['Minimum', entry.minimum ?? entry.min], + ['Maximum', entry.maximum ?? entry.max], + ['Multiple of', entry.multipleOf], + ]; + numericRules.forEach(([label, value]) => { + if (value !== undefined && value !== null && String(value).trim() !== '') { + rules.push(`${label}: ${value}`); + } + }); + + const pattern = String(entry.pattern ?? '').trim(); + if (pattern) { + rules.push(`Pattern: ${pattern}`); + } + + const allowedValues = entry.allowedValues || entry.choices || entry.enumValues || []; + if (Array.isArray(allowedValues) && allowedValues.length > 0) { + rules.push(`Allowed values: ${allowedValues.join(', ')}`); + } + + return rules; +} + +function buildParamHelpHtml(entry = {}) { + const description = String(entry.description || '').trim(); + const examples = [...new Set([...toExampleList(entry.example), ...toExampleList(entry.examples)])]; + const rules = buildParamValidationRules(entry); + const sections = [`

${escapeHtml(entry.name || 'Param')}

`]; + + if (description) { + sections.push(`

${escapeHtml(description)}

`); + } + + sections.push(`

Type: ${escapeHtml(entry.type || 'unknown')}

`); + + if (examples.length > 0) { + sections.push( + `

Examples:

    ${examples + .map((example) => `
  • ${escapeHtml(example)}
  • `) + .join('')}
` + ); + } + + if (rules.length > 0) { + sections.push( + `

Rules:

    ${rules.map((rule) => `
  • ${escapeHtml(rule)}
  • `).join('')}
` + ); + } + + return sections.join(''); +} + function renderEntryRows(entries = []) { return entries - .map( - (entry, index) => ` + .map((entry, index) => { + const helpHtml = buildParamHelpHtml(entry); + return ` - + +
+ + +
+ ${escapeHtml(entry.type || 'unknown')}