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 - ? `` + ? `
${escapeHtml(entry.name || 'Param')}
`]; + + if (description) { + sections.push(`${escapeHtml(description)}
`); + } + + sections.push(`Type: ${escapeHtml(entry.type || 'unknown')}
Examples:
${escapeHtml(example)}Rules:
${escapeHtml(title)}
`]; + + if (description) { + sections.push(`${escapeHtml(description)}
`); + } + + if (docsUrl) { + sections.push( + `` + ); + } + + 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 ` + + `; + } + + 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 `
+ ${escapeHtml(entry.type || 'unknown')}Default: ${escapeHtml(entry.defaultValue)}
${escapeHtml(commandLabel)}
+ ${ + commandHelpHtml + ? `` + : '' + } +${escapeHtml(helpModel?.summary || 'Provide param values and review the generated syntax before applying.')}
+ ${ + parsed.error + ? `${escapeHtml(parsed.error)}
` + : `| Name | +Type | +Req | +Value | +
|---|
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();
+ });
+});