diff --git a/apps/web/src/stories/shared-schema-definition.stories.js b/apps/web/src/stories/shared-schema-definition.stories.js index c90d44d0..760328e1 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 { @@ -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', }, }; @@ -423,3 +425,58 @@ export const CommandPicker = { await expect(commandInput?.value).toBe('helpers.fake'); }, }; + +export const ParamsDialog = { + render: renderSharedSchemaDefinitionStory, + args: { + storyMinHeight: '820px', + initialRows: [ + { + id: 'sequence-row', + name: 'SequenceId', + sourceType: 'domain', + command: 'autoIncrement.sequence', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }, + ], + }, + parameters: { + layout: 'fullscreen', + 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, 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.', + }, + }, + }, + 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 .*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 }); + 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( + '(start=1,step=1,prefix="filename",zeropadding=0)' + ) + ); + }, +}; 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..95896d1c --- /dev/null +++ b/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js @@ -0,0 +1,35 @@ +const { expect } = require('@playwright/test'); + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +class ParamsEditorDialogComponent { + constructor(page) { + this.page = page; + this.overlay = page.locator('[data-role="params-editor-overlay"]'); + this.dialog = page.getByRole('dialog', { name: /^edit params for /i }); + this.applyButton = this.dialog.getByRole('button', { name: /^apply$/i }); + } + + async expectOpen() { + await expect(this.dialog).toBeVisible(); + } + + valueInput(name) { + return this.dialog.getByRole('textbox', { name: new RegExp(`^${escapeRegExp(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..60615931 100644 --- a/docs/frontend-component-migration-plan.md +++ b/docs/frontend-component-migration-plan.md @@ -305,6 +305,8 @@ 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/domain-command-help-metadata.js b/packages/core-ui/js/gui_components/shared/domain-command-help-metadata.js index 81c4db6e..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 @@ -12,7 +12,16 @@ 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', + 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/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/schema/shared-schema-editor-controller.js b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js index 11609b3f..5e114b11 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,45 @@ 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; + } + if (typeof getMethodPickerOptions !== 'function') { + 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: [] }; @@ -546,7 +584,43 @@ function createSharedSchemaEditorController({ syncTextFromRows(); scheduleSemanticValidationForRow(rowId, { immediate: true }); } - } catch { + } catch (error) { + console.error('Failed opening schema method picker.', error); + return; + } + } + 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]; + try { + const methodOption = getMethodOptionForRow(row); + 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 (error) { + console.error('Failed opening params editor dialog.', error); return; } } 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..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 @@ -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/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/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..4158d0c0 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css @@ -0,0 +1,284 @@ +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-subtitle-row { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.params-editor-close, +.params-editor-footer button, +[data-role='params-editor-value'] { + 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-error[hidden] { + display: none; +} + +.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-name-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.params-editor-help-icon { + flex: 0 0 auto; +} + +[data-role='params-editor-value'] { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + padding: 8px 10px; + box-sizing: border-box; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.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-boolean-group { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + min-height: 38px; + margin: 0; + padding: 0; + border: 0; +} + +.params-editor-boolean-option { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 38px; + color: var(--pe-text, #111); +} + +.params-editor-boolean-option input[type='radio'] { + margin: 0; + accent-color: var(--pe-accent, #3366ff); +} + +.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 { + 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..6f3a64e7 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js @@ -0,0 +1,780 @@ +import { createHelpTooltipService } from '../../../../help/help-tooltips.js'; +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 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 = []; + 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 splitTopLevelNamedAssignment(text) { + const value = String(text ?? '').trim(); + 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) { + if (char === quote && previous !== '\\') { + quote = ''; + } + continue; + } + + if (char === '"' || char === "'") { + quote = 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 (char === '=' && depthParen === 0 && depthBracket === 0 && depthBrace === 0) { + return { + name: value.slice(0, index).trim(), + rawValue: value.slice(index + 1).trim(), + }; + } + } + + return null; +} + +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 variadicIndex = metadata.findIndex((param) => param?.variadic === true); + const hasVariadicTail = variadicIndex >= 0 && variadicIndex === metadata.length - 1; + const trimmed = stripOuterParens(initialParams); + if (!trimmed) { + return { + entries: metadata.map((param) => ({ + name: param?.name || '', + 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', + })), + error: '', + }; + } + + const split = splitTopLevelCommaSeparated(trimmed); + if (split.error) { + return { entries: [], error: split.error }; + } + const namedParamIndex = new Map( + metadata + .map((param, index) => [ + String(param?.name || '') + .trim() + .toLowerCase(), + index, + ]) + .filter(([name]) => Boolean(name)) + ); + const assignedValues = new Array(metadata.length).fill(''); + const explicitlyAssigned = new Set(); + const positionalValues = []; + + split.values.forEach((value) => { + const namedAssignment = splitTopLevelNamedAssignment(value); + const paramIndex = namedAssignment ? namedParamIndex.get(namedAssignment.name.toLowerCase()) : undefined; + if (namedAssignment && paramIndex !== undefined) { + assignedValues[paramIndex] = namedAssignment.rawValue; + explicitlyAssigned.add(paramIndex); + return; + } + positionalValues.push(value); + }); + + if (positionalValues.length > metadata.length && !hasVariadicTail) { + return { + entries: [], + error: + 'Current params use more values than the documented fields. Edit the raw params text directly for this command.', + }; + } + + let positionalCursor = 0; + metadata.forEach((param, index) => { + if (explicitlyAssigned.has(index)) { + return; + } + if (hasVariadicTail && index === variadicIndex) { + assignedValues[index] = positionalValues.slice(positionalCursor).join(','); + positionalCursor = positionalValues.length; + return; + } + assignedValues[index] = positionalValues[positionalCursor] || ''; + positionalCursor += 1; + }); + + if (positionalCursor < positionalValues.length && !hasVariadicTail) { + 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 = assignedValues[index] || ''; + return { + name: param?.name || '', + 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 || ''), + }; + }), + 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(unquoteValue(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) { + if (entry.optional === true) { + continue; + } + 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; + } + } + const formattedValue = formatEditorValue(rawValue, entry.mode || 'auto', entry.type || ''); + formattedValues.push(entry.name && entry.variadic !== true ? `${entry.name}=${formattedValue}` : formattedValue); + } + + 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 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:

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

Rules:

` + ); + } + + return sections.join(''); +} + +function buildCommandHelpHtml(helpModel = {}, commandLabel = '') { + const title = String(helpModel.heading || commandLabel || 'Command').trim(); + const description = String(helpModel.summary || helpModel.description || '').trim(); + const docsUrl = String(helpModel.docsUrl || '').trim(); + const sections = [`

${escapeHtml(title)}

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

${escapeHtml(description)}

`); + } + + if (docsUrl) { + sections.push( + `

Learn more

` + ); + } + + return sections.join(''); +} + +function isBooleanParamType(paramType = '') { + return /\b(bool|boolean)\b/iu.test(String(paramType || '')); +} + +function renderValueEditor(entry, index) { + if (isBooleanParamType(entry.type || '')) { + const value = String(entry.value ?? '') + .trim() + .toLowerCase(); + const radioName = `params-editor-boolean-${index}`; + const hasUnsetOption = entry.optional === true; + + return ` +
+ ${escapeHtml(entry.name)} value + ${ + hasUnsetOption + ? `` + : '' + } + + +
+ `; + } + + return ` + + `; +} + +function readRenderedEntryValue(rootElement, entry, index) { + if (isBooleanParamType(entry?.type || '')) { + const checkedBooleanOption = rootElement.querySelector( + `[data-role="params-editor-boolean"][data-index="${index}"]:checked` + ); + return checkedBooleanOption?.value ?? ''; + } + + return ( + rootElement.querySelector(`[data-role="params-editor-value"][data-index="${index}"]`)?.value ?? entry?.value ?? '' + ); +} + +function renderEntryRows(entries = []) { + return entries + .map((entry, index) => { + const helpHtml = buildParamHelpHtml(entry); + const nameLabel = isBooleanParamType(entry.type || '') + ? `${escapeHtml(entry.name)}` + : ``; + return ` + + +
+ ${nameLabel} + +
+ + ${escapeHtml(entry.type || 'unknown')} + + + + + ${renderValueEditor(entry, index)} + ${ + entry.defaultValue + ? `

Default: ${escapeHtml(entry.defaultValue)}

` + : '' + } + + + `; + }) + .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'); + const commandHelpHtml = buildCommandHelpHtml(helpModel, commandLabel); + 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 booleanInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-boolean"]')); + const helpTooltipService = createHelpTooltipService({ + documentObj, + windowObj, + rootElement: overlay, + entries: {}, + useGlobalInlineHelpContainer: false, + resolveHelpElements: () => overlay.querySelectorAll('[data-help-role][data-help]'), + }); + const warningVisible = Boolean(parsed.error); + let currentEntries = parsed.entries.map((entry) => ({ ...entry })); + + function setErrorMessage(message = '') { + const resolvedMessage = String(message || ''); + errorElement.textContent = resolvedMessage; + errorElement.hidden = resolvedMessage.length === 0; + } + + function syncPreview() { + if (warningVisible) { + previewElement.textContent = initialParams || ''; + setErrorMessage(''); + applyButton.disabled = true; + applyButton.setAttribute('aria-disabled', 'true'); + return; + } + + currentEntries = currentEntries.map((entry, index) => ({ + ...entry, + value: readRenderedEntryValue(overlay, entry, index), + })); + const result = buildParamsTextFromEditorEntries({ + entries: currentEntries, + validateParams, + }); + previewElement.textContent = result.paramsText || '()'; + setErrorMessage(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) { + helpTooltipService.destroy(); + 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)); + booleanInputs().forEach((input) => input.addEventListener('change', syncPreview)); + + documentObj.body.appendChild(overlay); + helpTooltipService.update(); + syncPreview(); + const firstInput = valueInputs()[0] || booleanInputs()[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/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/shared/shared-schema-definition-view.test.js b/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js index 88cf684f..de30bf96 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('(values=active,inactive,pending)'); + expect(component.getSchemaText()).toContain('enum(values=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..e91c0a14 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 @@ -1,5 +1,6 @@ import { jest } from '@jest/globals'; import { JSDOM } from 'jsdom'; +import { fireEvent, within } from '@testing-library/dom'; import * as schemaControllerExports from '../../../js/gui_components/shared/test-data/schema/schema-controller.js'; import * as schemaEditorCoreExports from '../../../js/gui_components/shared/test-data/schema/schema-editor-core.js'; import { createSharedSchemaEditorController } from '../../../js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js'; @@ -24,12 +25,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 +99,157 @@ 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 = within(dom.window.document.body).getByRole('dialog', { name: /edit params for datatype\.enum/i }); + expect(dialog).not.toBeNull(); + const paramsInput = within(dialog).getByRole('textbox', { name: /values value/i }); + paramsInput.value = 'active,inactive,pending'; + fireEvent.input(paramsInput); + fireEvent.click(within(dialog).getByRole('button', { name: /^apply$/i })); + + await dialogPromise; + expect(dom.window.document.querySelector('[data-field="params"]').value).toBe('(values=active,inactive,pending)'); + }); + + test('logs unexpected params editor failures instead of silently swallowing them', async () => { + const root = createRoot(dom.window.document); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + 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: () => { + throw new Error('boom from params dialog'); + }, + 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"]'); + + await controller.handleClick({ target: paramsButton }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed opening params editor dialog.', + expect.objectContaining({ message: 'boom from params dialog' }) + ); + expect(dom.window.document.querySelector('[data-field="params"]').value).toBe(''); + }); + + test('still opens the params dialog safely when getMethodPickerOptions is not a function', async () => { + const root = createRoot(dom.window.document); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + 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: undefined, + 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 = within(dom.window.document.body).getByRole('dialog', { name: /edit params for datatype\.enum/i }); + expect(dialog).not.toBeNull(); + expect(dom.window.document.querySelector('[data-field="params"]').value).toBe(''); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); + await expect(dialogPromise).resolves.toBeUndefined(); + }); }); 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/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; + }); }); 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..9460af43 --- /dev/null +++ b/packages/core-ui/src/tests/utils/params-editor-modal.test.js @@ -0,0 +1,456 @@ +import { JSDOM } from 'jsdom'; +import { fireEvent, within } from '@testing-library/dom'; +import { jest } from '@jest/globals'; +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; + global.tippy = jest.fn(); + dom.window.tippy = global.tippy; + getOverlay = () => document.querySelector('[data-role="params-editor-overlay"]'); + }); + + afterEach(() => { + dom.window.close(); + delete global.document; + delete global.window; + delete global.navigator; + delete global.tippy; + delete dom.window.tippy; + }); + + 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('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 }], + initialParams: '(active,inactive,pending)', + }); + + expect(parsed.error).toBe(''); + expect(parsed.entries).toEqual([ + expect.objectContaining({ name: 'values', value: 'active,inactive,pending', mode: 'raw' }), + ]); + }); + + test('maps named params to matching documented fields instead of positional slots', () => { + const parsed = parseInitialParamEntries({ + params: [ + { name: 'start', type: 'string|number', optional: true }, + { name: 'step', type: 'number', optional: true, defaultValue: '1' }, + { name: 'type', type: 'string', optional: true }, + { name: 'outputFormat', type: 'string', optional: true }, + { name: 'inputFormat', type: 'string', optional: true }, + ], + initialParams: '(step=10,outputFormat="YYYY-MM-DD")', + }); + + expect(parsed.error).toBe(''); + expect(parsed.entries).toEqual([ + expect.objectContaining({ name: 'start', value: '' }), + expect.objectContaining({ name: 'step', value: '10' }), + expect.objectContaining({ name: 'type', value: '' }), + expect.objectContaining({ name: 'outputFormat', value: 'YYYY-MM-DD' }), + expect.objectContaining({ name: 'inputFormat', value: '' }), + ]); + }); + + test('supports mixed positional and named params in the same invocation', () => { + const parsed = parseInitialParamEntries({ + params: [ + { name: 'start', type: 'string|number', optional: true }, + { name: 'step', type: 'number', optional: true }, + { name: 'type', type: 'string', optional: true }, + { name: 'outputFormat', type: 'string', optional: true }, + ], + initialParams: '("2026-06-12T12:39:23Z",step=15,outputFormat="yyyy-MM-dd")', + }); + + expect(parsed.error).toBe(''); + expect(parsed.entries).toEqual([ + expect.objectContaining({ name: 'start', value: '2026-06-12T12:39:23Z' }), + expect.objectContaining({ name: 'step', value: '15' }), + expect.objectContaining({ name: 'type', value: '' }), + expect.objectContaining({ name: 'outputFormat', value: 'yyyy-MM-dd' }), + ]); + }); + + 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: '(locale="en-GB",items=["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('(second="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=style=13)', + errors: ['Row 1: invalid domain params - unsupported option.'], + }); + }); + + 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: '(prefix="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, + 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(error.hidden).toBe(false); + expect(preview.textContent).toBe('()'); + + input.value = 'active,inactive,pending'; + fireEvent.input(input); + + expect(applyButton.disabled).toBe(false); + expect(error.textContent).toBe(''); + expect(error.hidden).toBe(true); + expect( + within(dialog).getByText('(values=active,inactive,pending)', { + selector: '[data-role="params-editor-preview"]', + }) + ).toBeTruthy(); + + fireEvent.click(applyButton); + await expect(promise).resolves.toBe('(values=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 error = dialog.querySelector('[data-role="params-editor-error"]'); + const applyButton = within(dialog).getByRole('button', { name: /^apply$/i }); + + expect(warning.textContent).toContain('documented fields'); + expect(error.textContent).toBe(''); + expect(error.hidden).toBe(true); + expect(applyButton.disabled).toBe(true); + + 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(); + }); + + 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(); + }); + + test('binds per-param tooltip help with descriptions, examples, and derived rules', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'number.int', + helpModel: { + summary: 'Integer helper', + params: [ + { + name: 'min', + type: 'integer', + optional: false, + description: 'Lower bound for the generated integer.', + example: '1', + }, + { + name: 'max', + type: 'integer', + optional: true, + defaultValue: '10', + description: 'Upper bound for the generated integer.', + examples: ['10', '100'], + }, + ], + }, + initialParams: '(1,10)', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for number\.int/i }); + const helpIcons = dialog.querySelectorAll('[data-role="params-editor-param-help"]'); + expect(helpIcons).toHaveLength(2); + expect(global.tippy).toHaveBeenCalled(); + + expect(helpIcons[0].getAttribute('data-help-text')).toContain('min'); + expect(helpIcons[0].getAttribute('data-help-text')).toContain('Lower bound for the generated integer.'); + expect(helpIcons[0].getAttribute('data-help-text')).toContain('Examples:'); + expect(helpIcons[0].getAttribute('data-help-text')).toContain('Rules:'); + expect(helpIcons[0].getAttribute('data-help-text')).toContain('Required.'); + + expect(helpIcons[1].getAttribute('data-help-text')).toContain('Optional.'); + expect(helpIcons[1].getAttribute('data-help-text')).toContain('Default: 10'); + expect(helpIcons[1].getAttribute('data-help-text')).toContain('100'); + + fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); + await expect(promise).resolves.toBeNull(); + }); + + test('adds command-level tooltip help next to the command label', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'number.int', + helpModel: { + heading: 'faker.number.int', + summary: 'Generates an integer within the configured range.', + docsUrl: 'https://example.com/docs/number-int', + params: [{ name: 'min', type: 'integer', optional: true }], + }, + initialParams: '', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for number\.int/i }); + const commandHelpIcon = dialog.querySelector('[data-role="params-editor-command-help"]'); + + expect(commandHelpIcon).toBeTruthy(); + expect(commandHelpIcon.getAttribute('data-help-text')).toContain('faker.number.int'); + expect(commandHelpIcon.getAttribute('data-help-text')).toContain( + 'Generates an integer within the configured range.' + ); + expect(commandHelpIcon.getAttribute('data-help-text')).toContain('https://example.com/docs/number-int'); + + fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); + await expect(promise).resolves.toBeNull(); + }); + + test('renders boolean params as radios instead of a text input and applies false', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'awd.domain.location.direction', + helpModel: { + summary: 'Returns a random direction.', + params: [{ name: 'abbreviated', type: 'boolean', optional: true }], + }, + initialParams: '', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { + name: /edit params for awd\.domain\.location\.direction/i, + }); + + expect(within(dialog).queryByRole('textbox', { name: /abbreviated value/i })).toBeNull(); + + const unsetRadio = within(dialog).getByRole('radio', { name: /unset/i }); + const trueRadio = within(dialog).getByRole('radio', { name: /true/i }); + const falseRadio = within(dialog).getByRole('radio', { name: /false/i }); + + expect(unsetRadio.checked).toBe(true); + expect(trueRadio.checked).toBe(false); + expect(falseRadio.checked).toBe(false); + + fireEvent.click(falseRadio); + + expect( + within(dialog).getByText('(abbreviated=false)', { + selector: '[data-role="params-editor-preview"]', + }) + ).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: /^apply$/i })); + await expect(promise).resolves.toBe('(abbreviated=false)'); + }); + + test('prefills required boolean params from existing values', async () => { + const promise = openParamsEditorModal({ + documentObj: document, + windowObj: window, + commandLabel: 'datatype.boolean', + helpModel: { + summary: 'Boolean helper', + params: [{ name: 'strict', type: 'boolean', optional: false }], + }, + initialParams: '(true)', + }); + + const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for datatype\.boolean/i }); + const trueRadio = within(dialog).getByRole('radio', { name: /true/i }); + const falseRadio = within(dialog).getByRole('radio', { name: /false/i }); + + expect(trueRadio.checked).toBe(true); + expect(falseRadio.checked).toBe(false); + + fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i })); + await expect(promise).resolves.toBeNull(); + }); +});