From 21aef64761940bc79b4b4976d552c2d364b09787 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 25 Jun 2026 16:43:34 +0100 Subject: [PATCH 01/20] Implement method picker MVC --- .../stories/method-picker-dialog.stories.js | 578 +++++++----------- docs/frontend-component-architecture.md | 9 +- docs/frontend-component-migration-plan.md | 3 +- docs/frontend-legacy-ui-elimination-plan.md | 9 +- .../shared/method-picker-dialog/index.js | 33 + .../method-help-display-controller.js | 20 + .../method-help-display-view.js | 60 ++ .../method-help-display.js | 23 + .../method-list-controller.js | 26 + .../method-picker-dialog/method-list-view.js | 57 ++ .../method-picker-dialog/method-list.js | 23 + .../method-navigator-controller.js | 27 + .../method-navigator-view.js | 73 +++ .../method-picker-dialog/method-navigator.js | 29 + .../method-picker-dialog-controller.js | 121 ++++ .../method-picker-dialog-utils.js | 317 ++++++++++ .../method-picker-dialog-view.js | 164 +++++ .../test-data/ui/method-picker-modal.js | 468 ++------------ .../method-picker-dialog-components.test.js | 124 ++++ .../method-picker-dialog-controller.test.js | 107 ++++ .../tests/utils/method-picker-modal.test.js | 50 ++ 21 files changed, 1526 insertions(+), 795 deletions(-) create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/index.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-controller.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-controller.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-view.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-controller.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-view.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-controller.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js create mode 100644 packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js create mode 100644 packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js create mode 100644 packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js diff --git a/apps/web/src/stories/method-picker-dialog.stories.js b/apps/web/src/stories/method-picker-dialog.stories.js index 0e46973e..a9f2524e 100644 --- a/apps/web/src/stories/method-picker-dialog.stories.js +++ b/apps/web/src/stories/method-picker-dialog.stories.js @@ -1,12 +1,15 @@ import React from 'react'; import { Canvas, Controls, Description, Title } from '@storybook/addon-docs/blocks'; import { expect, fn, userEvent, within } from 'storybook/test'; +import { createMethodHelpDisplay } from '../../../../packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display.js'; +import { createMethodList } from '../../../../packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list.js'; +import { createMethodNavigator } from '../../../../packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator.js'; +import { createMethodPickerDialog } from '../../../../packages/core-ui/js/gui_components/shared/method-picker-dialog/index.js'; +import { RECENT_STORAGE_KEY } from '../../../../packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js'; import { openMethodPickerModal } from '../../../../packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js'; import { buildSchemaHelpModel } from '../../../../packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js'; -const METHOD_PICKER_RECENT_STORAGE_KEY = 'anywaydata.method-picker.recent'; const METHOD_PICKER_STYLE_ID = 'storybook-method-picker-modal-styles-link'; -const CORE_COMMANDS = new Set(['enum', 'literal', 'regex']); const METHOD_OPTION_SPECS = Object.freeze([ { sourceType: 'regex', command: 'regex', helpCommand: '' }, @@ -24,56 +27,14 @@ function buildMethodOptions() { } const METHOD_OPTIONS = buildMethodOptions(); - -function escapeHtml(text) { - return String(text ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -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 getReturnExamples(model) { - const unique = new Set(); - const usageExamples = Array.isArray(model?.usageExamples) ? model.usageExamples : []; - usageExamples.forEach((usageExample) => { - if (Object.prototype.hasOwnProperty.call(usageExample || {}, 'sampleReturnValue')) { - unique.add(String(usageExample.sampleReturnValue ?? '').trim()); - } - }); - toExampleList(model?.returnExamples).forEach((entry) => unique.add(entry)); - return [...unique].filter(Boolean); -} - -function getUsageFunctionCalls(model) { - return (Array.isArray(model?.usageExamples) ? model.usageExamples : []) - .map((usageExample) => String(usageExample?.functionCall || '').trim()) - .filter(Boolean); -} - -function buildSearchText(option) { - const params = Array.isArray(option?.helpModel?.params) ? option.helpModel.params.map((p) => p?.name || '') : []; - const usageExamples = getUsageFunctionCalls(option?.helpModel); - const returnExamples = getReturnExamples(option?.helpModel); - return [ - option.command, - option.helpModel?.summary || '', - usageExamples.join(' '), - returnExamples.join(' '), - params.join(' '), - ] - .join(' ') - .toLowerCase(); -} +const TAB_SPECS = [ + { id: 'all', label: 'All' }, + { id: 'core', label: 'Core' }, + { id: 'domain:commerce', label: 'commerce' }, + { id: 'domain:internet', label: 'internet' }, + { id: 'faker', label: 'Faker' }, + { id: 'recent', label: 'Recently used' }, +]; function ensureMethodPickerStyles(documentObj) { if (!documentObj?.head || documentObj.getElementById(METHOD_PICKER_STYLE_ID)) { @@ -89,284 +50,132 @@ function ensureMethodPickerStyles(documentObj) { documentObj.head.appendChild(link); } -function renderExampleList(examples, emptyText) { - if (!examples.length) { - return `

${escapeHtml(emptyText)}

`; - } - const rows = examples.map((example) => `
  • ${escapeHtml(example)}
  • `).join(''); - return ``; -} - -function renderParameterDetailsTable(model) { - if (!Array.isArray(model?.params) || model.params.length === 0) { - return '

    No params

    '; - } - const rows = model.params - .map((param) => { - const description = String(param.description || '').trim(); - const example = String(param.example || param.examples || '').trim(); - const details = [description, example ? `Example: ${example}` : ''].filter(Boolean).join(' '); - return `${escapeHtml(param.name)}${escapeHtml(details || '-')}`; - }) - .join(''); - return `${rows}
    NameDetails
    `; -} - -function renderParameterTypesTable(model) { - if (!Array.isArray(model?.params) || model.params.length === 0) { - return '

    No params

    '; - } - const rows = model.params - .map((param) => { - const optional = param.optional ? 'optional' : 'required'; - return `${escapeHtml(param.name)}${escapeHtml( - param.type || 'unknown' - )}${optional}`; - }) - .join(''); - return `${rows}
    NameTypeReq
    `; -} - -function createVisualMethodPickerStory(root, args) { - ensureMethodPickerStyles(document); - - const prepared = METHOD_OPTIONS.map((option) => ({ - ...option, - searchText: buildSearchText(option), - })); - const domainCategories = [ - ...new Set( - prepared - .filter((option) => option.sourceType === 'domain' && String(option.command || '').includes('.')) - .map((option) => String(option.command).split('.')[0]) - ), - ].sort((left, right) => left.localeCompare(right)); - const tabSpecs = [ - { id: 'all', label: 'All' }, - { id: 'core', label: 'Core' }, - ...domainCategories.map((category) => ({ id: `domain:${category}`, label: category })), - { id: 'faker', label: 'Faker' }, - { id: 'recent', label: 'Recently used' }, - ]; - - let activeTab = args.initialTab || 'all'; - let selectedCommand = args.currentCommand || 'helpers.arrayElement'; - let searchValue = ''; - +function createRootWithLog() { + const root = document.createElement('section'); root.style.display = 'grid'; root.style.gap = '0.75rem'; - root.innerHTML = ` -
    -
    - -
    -
    - No actions yet. - `; - - const overlay = root.querySelector('[data-role="method-picker-overlay"]'); - const searchInput = root.querySelector('[data-role="method-picker-search"]'); - const tabsElem = root.querySelector('[data-role="method-picker-tabs"]'); - const listElem = root.querySelector('[data-role="method-picker-list"]'); - const detailElem = root.querySelector('[data-role="method-picker-detail"]'); - const applyButton = root.querySelector('[data-role="method-picker-apply-button"]'); - const log = root.querySelector('[data-role="visual-method-picker-log"]'); - - function writeLog(actionName) { - log.textContent = `action:${actionName}`; - } - - function emitAction(actionName, payload) { - writeLog(actionName); - if (actionName === 'apply') { - args.onApply?.(payload); - } else if (actionName === 'cancel') { - args.onCancel?.(); - } else if (actionName === 'close') { - args.onClose?.(); - } else if (actionName === 'backdrop') { - args.onBackdrop?.(); - } - } - - function getSelected() { - return prepared.find((option) => option.command === selectedCommand) || null; - } - - function getFiltered() { - return prepared.filter((option) => { - if (activeTab === 'core' && !CORE_COMMANDS.has(String(option.command || '').toLowerCase())) { - return false; - } - if (activeTab === 'faker' && option.sourceType !== 'faker') { - return false; - } - if (activeTab === 'recent') { - return option.command === selectedCommand; - } - if (activeTab.startsWith('domain:')) { - if (option.sourceType !== 'domain') { - return false; - } - const category = activeTab.split(':')[1] || ''; - if (!String(option.command || '').startsWith(`${category}.`)) { - return false; - } - } - if (!searchValue) { - return true; - } - return option.searchText.includes(searchValue); - }); - } - - function renderDetail() { - const selected = getSelected(); - if (!selected) { - detailElem.innerHTML = '

    No method selected

    '; - return; - } - const model = selected.helpModel || {}; - const usageExamples = getUsageFunctionCalls(model); - const returnExamples = getReturnExamples(model); - const docsUrl = String(model.docsUrl || '').trim(); - const hasParams = Array.isArray(model.params) && model.params.length > 0; - detailElem.innerHTML = ` -

    ${escapeHtml(selected.command)}

    -

    ${escapeHtml(model.summary || 'No summary available.')}

    -

    Schema: ${escapeHtml(model.heading || selected.command)}()

    -
    Parameter Details
    -
    ${renderParameterDetailsTable(model)}
    - ${hasParams ? `
    Parameter Types
    ${renderParameterTypesTable(model)}
    ` : ''} - ${usageExamples.length ? `
    Usage Examples
    ${renderExampleList(usageExamples, '')}` : ''} -
    Return Examples
    - ${renderExampleList(returnExamples, 'No return examples available')} - ${ - docsUrl - ? `` - : '' - } - `; - } - - function syncApplyButtonState() { - applyButton.disabled = !selectedCommand; - applyButton.setAttribute('aria-disabled', applyButton.disabled ? 'true' : 'false'); - } - - function renderTabs() { - tabsElem.innerHTML = tabSpecs - .map( - (tab) => - `` - ) - .join(''); - } - - function renderList() { - const filtered = getFiltered(); - if (!filtered.some((option) => option.command === selectedCommand)) { - selectedCommand = filtered[0]?.command || ''; - } - listElem.innerHTML = filtered - .map((option) => { - const isSelected = option.command === selectedCommand; - return ` - `; - }) - .join(''); - renderDetail(); - syncApplyButtonState(); - } - - function rerender() { - renderTabs(); - renderList(); - } + root.innerHTML = 'No actions yet.'; + return { + root, + log: root.querySelector('[data-role="story-log"]'), + }; +} - tabsElem.addEventListener('click', (event) => { - const tabId = event.target?.closest?.('[data-tab]')?.getAttribute?.('data-tab'); - if (!tabId) { - return; - } - activeTab = tabId; - rerender(); +function renderNavigatorStory(args) { + ensureMethodPickerStyles(document); + const { root, log } = createRootWithLog(); + const host = document.createElement('div'); + root.prepend(host); + let state = { + searchTerm: args.searchTerm, + activeTab: args.activeTab, + tabSpecs: TAB_SPECS, + }; + let component = null; + component = createMethodNavigator({ + root: host, + props: state, + callbacks: { + onSearchTermChange: (searchTerm) => { + state = { ...state, searchTerm }; + log.textContent = `search:${searchTerm}`; + args.onSearchTermChange?.(searchTerm); + component.update(state); + }, + onTabChange: (activeTab) => { + state = { ...state, activeTab }; + log.textContent = `tab:${activeTab}`; + args.onTabChange?.(activeTab); + component.update(state); + }, + }, }); + root.__storybookCleanup = () => component.destroy(); + return root; +} - listElem.addEventListener('click', (event) => { - const command = event.target?.closest?.('[data-command]')?.getAttribute?.('data-command'); - if (!command) { - return; - } - selectedCommand = command; - renderList(); +function renderListStory(args) { + ensureMethodPickerStyles(document); + const { root, log } = createRootWithLog(); + const host = document.createElement('section'); + root.prepend(host); + let selectedCommand = args.selectedCommand; + let component = null; + component = createMethodList({ + root: host, + props: { + selectedCommand, + options: METHOD_OPTIONS, + }, + callbacks: { + onSelectCommand: (command) => { + selectedCommand = command; + log.textContent = `selected:${command}`; + args.onSelectCommand?.(command); + component.update({ selectedCommand, options: METHOD_OPTIONS }); + }, + }, }); + root.__storybookCleanup = () => component.destroy(); + return root; +} - searchInput.addEventListener('input', () => { - searchValue = String(searchInput.value || '') - .trim() - .toLowerCase(); - renderList(); +function renderHelpDisplayStory(args) { + ensureMethodPickerStyles(document); + const root = document.createElement('section'); + const selectedOption = METHOD_OPTIONS.find((option) => option.command === args.selectedCommand) || METHOD_OPTIONS[0]; + const component = createMethodHelpDisplay({ + root, + props: { selectedOption }, }); + root.__storybookCleanup = () => component.destroy(); + return root; +} - overlay.addEventListener('click', (event) => { - const selected = getSelected(); - if (event.target === overlay) { - emitAction('backdrop'); - return; - } - if (event.target?.closest?.('[data-role="method-picker-close-button"]')) { - emitAction('close'); - return; - } - if (event.target?.closest?.('[data-role="method-picker-cancel-button"]')) { - emitAction('cancel'); - return; - } - if (event.target?.closest?.('[data-role="method-picker-apply-button"]')) { - if (selected) { - emitAction('apply', { sourceType: selected.sourceType, command: selected.command }); - } - } +function renderVisualMethodPickerDialogStory(args) { + ensureMethodPickerStyles(document); + const { root, log } = createRootWithLog(); + const frame = document.createElement('div'); + frame.setAttribute('data-role', 'visual-method-picker-frame'); + frame.style.position = 'relative'; + frame.style.minHeight = '780px'; + const overlayRoot = document.createElement('div'); + frame.appendChild(overlayRoot); + root.prepend(frame); + + const component = createMethodPickerDialog({ + root: overlayRoot, + documentObj: document, + props: { + title: args.title, + options: METHOD_OPTIONS, + currentCommand: args.currentCommand, + initialTab: args.initialTab, + }, + callbacks: { + onApply: (selection) => { + log.textContent = `action:apply:${selection.command}`; + args.onApply?.(selection); + }, + onCancel: ({ reason } = {}) => { + log.textContent = `action:${reason || 'cancel'}`; + if (reason === 'close') { + args.onClose?.(); + } else if (reason === 'backdrop') { + args.onBackdrop?.(); + } else { + args.onCancel?.(); + } + }, + }, }); - - rerender(); + overlayRoot.style.position = 'absolute'; + overlayRoot.style.inset = '0'; + root.__storybookCleanup = () => { + component.destroy(); + document.defaultView?.localStorage?.removeItem?.(RECENT_STORAGE_KEY); + }; + return root; } function renderMethodPickerDialogStory(args) { @@ -396,23 +205,13 @@ function renderMethodPickerDialogStory(args) { }; root.querySelector('[data-action="open"]')?.addEventListener('click', openDialog); - root.__storybookCleanup = () => { - windowObj?.localStorage?.removeItem?.(METHOD_PICKER_RECENT_STORAGE_KEY); + windowObj?.localStorage?.removeItem?.(RECENT_STORAGE_KEY); }; return root; } -function renderVisualMethodPickerDialogStory(args) { - const root = document.createElement('section'); - createVisualMethodPickerStory(root, args); - root.__storybookCleanup = () => { - document.defaultView?.localStorage?.removeItem?.(METHOD_PICKER_RECENT_STORAGE_KEY); - }; - return root; -} - const meta = { title: 'Shared/Method Picker Dialog', tags: ['autodocs'], @@ -425,6 +224,9 @@ const meta = { React.createElement(Title), React.createElement(Description), React.createElement(Controls), + React.createElement(Canvas, { of: NavigatorDefault }), + React.createElement(Canvas, { of: ListDefault }), + React.createElement(Canvas, { of: HelpDisplayWithUsage }), React.createElement(Canvas, { of: VisualAlwaysOpen }), React.createElement(Canvas, { of: ChooseFakerMethod }), React.createElement(Canvas, { of: FilterAndChooseDomainMethod }), @@ -432,7 +234,7 @@ const meta = { ), description: { component: - 'Storybook coverage for the shared method-picker dialog. The visual always-open example is reviewer-facing and non-dismissing so the UI can be inspected directly, while the other stories still demonstrate the real promise-based open/apply/cancel service flow.', + 'Component-backed Method Picker Dialog coverage. Navigator, list, help display, and composed dialog stories all mount the real MVC components; the service-flow stories still demonstrate the promise-based open/apply/cancel compatibility API.', }, }, }, @@ -450,43 +252,115 @@ const meta = { options: ['all', 'core', 'faker', 'domain:commerce', 'domain:internet', 'recent'], description: 'Initial tab shown when the picker opens.', }, - onApply: { - description: 'Storybook action fired when the visual always-open example simulates Apply.', - table: { category: 'Events' }, - }, - onCancel: { - description: 'Storybook action fired when the visual always-open example simulates Cancel.', - table: { category: 'Events' }, + selectedCommand: { + control: 'select', + options: METHOD_OPTIONS.map((option) => option.command), + description: 'Selected command for focused list/help stories.', }, - onClose: { - description: 'Storybook action fired when the visual always-open example simulates the close button.', - table: { category: 'Events' }, + searchTerm: { + control: 'text', + description: 'Initial navigator search text for focused navigator stories.', }, - onBackdrop: { - description: 'Storybook action fired when the visual always-open example simulates a backdrop click.', - table: { category: 'Events' }, + activeTab: { + control: 'select', + options: TAB_SPECS.map((tab) => tab.id), + description: 'Initial navigator active tab for focused navigator stories.', }, + onApply: { table: { category: 'Events' } }, + onCancel: { table: { category: 'Events' } }, + onClose: { table: { category: 'Events' } }, + onBackdrop: { table: { category: 'Events' } }, + onSearchTermChange: { table: { category: 'Events' } }, + onTabChange: { table: { category: 'Events' } }, + onSelectCommand: { table: { category: 'Events' } }, }, args: { title: 'Choose Method', currentCommand: 'helpers.arrayElement', initialTab: 'all', + selectedCommand: 'helpers.arrayElement', + searchTerm: '', + activeTab: 'all', onApply: fn(), onCancel: fn(), onClose: fn(), onBackdrop: fn(), + onSearchTermChange: fn(), + onTabChange: fn(), + onSelectCommand: fn(), }, }; export default meta; +export const NavigatorDefault = { + render: renderNavigatorStory, + parameters: { + docs: { + description: { + story: + 'Focused Method Navigator story. Type in the filter or switch tabs and watch the story log record the emitted component events.', + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByRole('searchbox', { name: 'Filter methods' }), 'city'); + await expect(canvas.getByText('search:city')).toBeVisible(); + await userEvent.click(canvas.getByRole('button', { name: 'Core' })); + await expect(canvas.getByText('tab:core')).toBeVisible(); + }, +}; + +export const ListDefault = { + render: renderListStory, + parameters: { + docs: { + description: { + story: + 'Focused Method List story. It renders method tiles with the selected command highlighted and emits a selection event when a tile is chosen.', + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole('button', { + name: 'commerce.price Generates a price between min and max (inclusive). domain', + }) + ); + await expect(canvas.getByText('selected:commerce.price')).toBeVisible(); + }, +}; + +export const HelpDisplayWithUsage = { + render: renderHelpDisplayStory, + args: { + selectedCommand: 'internet.password', + }, + parameters: { + docs: { + description: { + story: + 'Focused Method Help Display story. It shows the selected method summary, schema heading, parameter tables, structured usage examples, return values, and docs link.', + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('heading', { name: 'internet.password' })).toBeVisible(); + await expect(canvas.getByText('Parameter Details')).toBeVisible(); + await expect(canvas.getByText('Usage Examples')).toBeVisible(); + }, +}; + export const VisualAlwaysOpen = { render: renderVisualMethodPickerDialogStory, parameters: { docs: { description: { story: - 'This reviewer-facing example renders the method picker immediately and intentionally never closes. **Apply**, **Cancel**, the close button, and the backdrop all report what would have happened in the log and the Actions panel, while the dialog stays visible for visual inspection.', + 'Reviewer-facing composed dialog story. The dialog stays open while Apply, Cancel, and Close log the action that the component emitted.', }, }, }, @@ -500,7 +374,7 @@ export const VisualAlwaysOpen = { ).toHaveClass('is-selected'); await userEvent.click(canvas.getByRole('button', { name: 'Apply' })); - await expect(canvas.getByText('action:apply')).toBeVisible(); + await expect(canvas.getByText('action:apply:helpers.arrayElement')).toBeVisible(); await expect(canvas.getByRole('dialog', { name: 'Choose Method' })).toBeVisible(); await userEvent.click(canvas.getByRole('button', { name: 'Cancel' })); @@ -510,10 +384,6 @@ export const VisualAlwaysOpen = { await userEvent.click(canvas.getByRole('button', { name: 'Close' })); await expect(canvas.getByText('action:close')).toBeVisible(); await expect(canvas.getByRole('dialog', { name: 'Choose Method' })).toBeVisible(); - - await userEvent.click(canvas.getByRole('button', { name: 'All' }).closest('[data-role="method-picker-overlay"]')); - await expect(canvas.getByText('action:backdrop')).toBeVisible(); - await expect(canvas.getByRole('dialog', { name: 'Choose Method' })).toBeVisible(); }, }; @@ -523,7 +393,7 @@ export const ChooseFakerMethod = { docs: { description: { story: - 'Click **Open method picker**, choose **helpers.arrayElement**, then confirm with **Apply**. This shows the normal confirmed-selection path and the visible promise result beneath the trigger button.', + 'Click Open method picker, choose helpers.arrayElement, then confirm with Apply. This demonstrates the normal confirmed-selection promise path.', }, }, }, @@ -551,7 +421,7 @@ export const FilterAndChooseDomainMethod = { docs: { description: { story: - 'Open the picker, type `commerce` into the filter, choose **commerce.price**, and apply. This demonstrates that the Storybook surface covers the searchable list behavior, not just a preselected tile.', + 'Open the picker, type commerce into the filter, choose commerce.price, and apply. This covers searchable list behavior through the compatibility service.', }, }, }, @@ -574,7 +444,7 @@ export const CancelMethodSelection = { docs: { description: { story: - 'Open the picker and choose **Cancel**. This demonstrates the dismissed overlay path and shows the `Cancelled` result in the story output.', + 'Open the picker and choose Cancel. This demonstrates the dismissed overlay path and shows the Cancelled result in the story output.', }, }, }, diff --git a/docs/frontend-component-architecture.md b/docs/frontend-component-architecture.md index a188d02a..a0f68084 100644 --- a/docs/frontend-component-architecture.md +++ b/docs/frontend-component-architecture.md @@ -86,7 +86,7 @@ Storybook is a review, documentation, and lightweight interaction-example layer. - When practical, presenter stories should include a destroy-and-remount example so reviewers can confirm lifecycle safety without reading Jest tests first. - Storybook cleanup is centralized in `.storybook/preview.js`; stories may expose `root.__storybookCleanup`, and the global decorator will run that teardown before the next story and remove common body-level artifacts such as modals, method-picker overlays, tooltip poppers, and inline help containers. - Prefer returning the story root directly instead of manually appending it to `document.body` unless the component behavior genuinely depends on top-level overlays or body-scoped positioning. -- Current intentional body-aware Storybook exception is the app page bootstrap story, because it still exercises document-scoped page/bootstrap behavior rather than a purely root-scoped component contract. Document-level overlay stories and interactions are also allowed to validate modal or method-picker behavior. +- Current intentional body-aware Storybook exception is the app page bootstrap story, because it still exercises document-scoped page/bootstrap behavior rather than a purely root-scoped component contract. Document-level overlay stories and interactions are also allowed to validate modal behavior, while method-picker stories should prefer the component root and use the compatibility service only when demonstrating the promise-based body-overlay API. ## Format Options @@ -145,9 +145,9 @@ These modules are intentionally kept as adapters or service-like helpers rather - `packages/core-ui/js/gui_components/shared/page-startup-loading-status.js` - Page bootstrap helper for the initial loading/failure status surface. - Acceptable because it is a page-runtime presenter helper, not a reusable feature component, and it delegates visible rendering to resolver-driven status presenter components while leaving page-level startup-element lookup to the app/generator bootstrap entry points. -- `packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js` - - Document-level modal/overlay helper for the schema method picker. - - Acceptable because it is an explicitly document-scoped service-style helper with injected `documentObj`/`windowObj`, not a reusable embedded component. +- `packages/core-ui/js/gui_components/shared/method-picker-dialog/` + - Component-backed schema method picker, split into navigator, method list, help display, and composed dialog MVC components. + - `packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js` remains only as a thin compatibility service that creates the body overlay, injects styles, restores focus, and delegates rendering/state to `createMethodPickerDialog(...)`. ### Grid and third-party adapters @@ -188,4 +188,3 @@ Current intentional browser-service and page-entry exceptions: - The supported runtime grid engine is now Tabulator only. - `packages/core-ui/js/gui_components/app/page/app-page-runtime.js` mounts `createDataGridComponent(...)` directly and injects the supported Tabulator services explicitly. - `packages/core-ui/js/gui_components/data-grid-editor/grid-library-loader.js` now loads only the Tabulator runtime assets. - diff --git a/docs/frontend-component-migration-plan.md b/docs/frontend-component-migration-plan.md index 98c0983f..9b4b8036 100644 --- a/docs/frontend-component-migration-plan.md +++ b/docs/frontend-component-migration-plan.md @@ -535,7 +535,7 @@ Current status: Use this backlog when the next migration step should be chosen from the reviewer-facing Storybook surface rather than from internal helper seams alone. -- [x] Add dedicated Storybook coverage for the method-picker dialog in `shared/test-data/ui/method-picker-modal.js`, including: +- [x] Add dedicated Storybook coverage for the component-backed method-picker dialog under `shared/method-picker-dialog/`, including: - confirmed selection flow - cancel flow - filtered or tab-scoped selection flow @@ -558,6 +558,7 @@ Current status: - The command picker dialog now has dedicated reviewer-facing Storybook coverage in `apps/web/src/stories/method-picker-dialog.stories.js`, with confirmed, cancelled, and filtered-selection flows. - The command picker dialog stories now also include a non-dismissing visual review example that renders the picker open immediately and logs `Apply`, `Cancel`, close-button, and backdrop actions without closing the overlay, so reviewers can inspect the component visually while the other stories keep covering the real promise-driven service flow. +- The method-picker dialog is now a real MVC component family under `shared/method-picker-dialog/`: `MethodNavigator`, `MethodList`, `MethodHelpDisplay`, and the composed `MethodPickerDialog` have focused tests and Storybook stories, while `openMethodPickerModal(...)` is only a compatibility service wrapper for the body-overlay promise API. - The shared confirm dialog and text-input dialog stories now follow that same review pattern with `Visual Always Open` examples, and the loading/status presenter stories now expose `Visual Always Visible` examples so the rendered presenter state can be reviewed immediately instead of only after pressing a trigger button. - `PopulationModeSelector` now has dedicated reviewer-facing Storybook coverage in `apps/web/src/stories/population-mode-selector.stories.js`, with default, emitted-change, and alternate-initial-mode states. - `PopulationActions` now has dedicated reviewer-facing Storybook coverage in `apps/web/src/stories/population-actions.stories.js`, and the action cluster is now reused by generator controls as a shared icon+tippy action component with host-specific HTML help content for app-to-grid versus generator-to-file flows. diff --git a/docs/frontend-legacy-ui-elimination-plan.md b/docs/frontend-legacy-ui-elimination-plan.md index 8ff21a0c..e3c1a1b4 100644 --- a/docs/frontend-legacy-ui-elimination-plan.md +++ b/docs/frontend-legacy-ui-elimination-plan.md @@ -135,7 +135,7 @@ Active adapters and shared helpers assigned to Phase 6: - `packages/core-ui/js/gui_components/shared/modal-confirm.js` and `packages/core-ui/js/gui_components/shared/modal-text-input.js`: imperative modal helpers wrapped by dialog services. - `packages/core-ui/js/help/help-tooltips.js`: scoped help-tooltip lifecycle is now resolver-driven and root-scoped, and page bootstrap now passes explicit page roots into `initHelpTooltips(...)`. The remaining page-level concern is the global inline-help registry contract rather than whole-document help-icon scanning. - `packages/core-ui/js/gui_components/shared/theme-toggle.js`: imperative page helper imported by app and generator startup. -- `packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js`: document-level modal-style UI helper that should remain service-like or become component-backed. +- `packages/core-ui/js/gui_components/shared/method-picker-dialog/`: component-backed method picker dialog. The old `packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js` path remains only as a thin service wrapper for the body-overlay promise API. - `packages/core-ui/js/gui_components/shared/test-data/ui/status-presenter.js`, `packages/core-ui/js/gui_components/shared/timed-error-display.js`, `packages/core-ui/js/gui_components/data-grid-editor/grid-error-surface.js`, and `packages/core-ui/js/gui_components/shared/primitives/inline-message/inline-message-view.js`: mostly accepted presenter/service or style-injection patterns, but they should stay documented as adapters rather than feature components. - `packages/core-ui/js/gui_components/shared/page-startup-loading-status.js`: startup-status helper should stay resolver-driven and page-bootstrap-owned rather than carrying page-level loading-element ID lookup inside the shared helper. @@ -411,7 +411,7 @@ Current status: - The low-level help-tooltip service no longer hard-codes `.helpicon[data-help]` scans inside its `update()` and `destroy()` lifecycle. It now consumes an explicit help-element resolver, leaving selector-based scans only in the page/bootstrap and scoped helper entrypoints rather than inside the shared service itself. - Generic shared help triggers now also expose explicit `data-help-role` contracts (`help-icon` and `option-help-icon`), and the shared scoped resolver now discovers tooltip targets through those hooks instead of the styling class `.helpicon`. - Theme toggle now exposes only the small `createThemeToggleComponent(...)` page-shell feature with explicit `getState()`, `toggleTheme()`, `setTheme()`, and `destroy()` lifecycle. App and generator bootstraps call that factory directly, so the old `initThemeToggle(...)` compatibility wrapper is gone. -- `docs/frontend-component-architecture.md` now includes an explicit accepted-adapter inventory covering download, clipboard/download/file-read helpers, drag/drop bindings, timer/startup helpers, method-picker modal, grid-library loading, grid/widget adapters, dialog services, help tooltips, and the theme toggle. +- `docs/frontend-component-architecture.md` now includes an explicit accepted-adapter inventory covering download, clipboard/download/file-read helpers, drag/drop bindings, timer/startup helpers, the method-picker compatibility service, grid-library loading, grid/widget adapters, dialog services, help tooltips, and the theme toggle. - Confirm and text-input dialog internals now also resolve focus scheduling through injected `windowObj` / `documentObj` instead of ambient global window access, which narrows another remaining service-level browser dependency behind the explicit dialog-service boundary. - The import/export download-service path now preserves injected `documentObj`, `URLObj`, and `BlobCtor` all the way into the `Download` adapter instead of silently reconstructing that adapter from ambient globals. - The low-level `Download` adapter itself now resolves `URL` and `Blob` from injected browser context before falling back to ambient globals, so direct adapter consumers follow the same explicit browser boundary as the service layer above it. @@ -434,8 +434,8 @@ Current status: - The shared theme-toggle component also no longer knows about `.header` as an implicit host contract, and the app/generator bootstraps no longer carry that fallback either. The page-entry HTML and page stories now expose an explicit `[data-role="theme-toggle-host"]` contract, and the shared feature itself only accepts that rooted host or an injected host element. - The import/export drag/drop helper no longer carries a class-style control surface in the live path. `createDragDropAdapter(...)` is now the runtime contract, `FileImportBindingsAdapter` injects that rooted adapter factory explicitly, and the adapter itself is limited to drop-zone event binding/CSS state plus forwarding the first dropped file. - The shared confirm/text-input modal helpers now resolve their owned dialog/title/message/input/button elements through rooted `data-role` hooks inside the overlay subtree rather than fixed child-ID or styling-class queries. The document-level backdrop and button/input IDs remain as the intentional public dialog-host contract for cleanup and browser/page-object integration. -- The shared method-picker modal now exposes rooted hooks for its overlay, search field, tabs, list, detail panel, tiles, and command labels. Its own internals, Jest coverage, browser page object, and Storybook cleanup now follow those component-owned hooks instead of the old styling-class selectors, while the classes remain as presentation markup. -- The shared method-picker modal’s close/cancel/apply controls now also use explicit rooted `data-role` hooks instead of ad hoc `data-action` attributes, so the live modal contract is more consistently component-owned end to end. +- The shared method-picker dialog now exposes rooted hooks for its overlay, search field, tabs, list, detail panel, tiles, and command labels through real MVC components. Its own internals, Jest coverage, browser page object, and Storybook cleanup follow those component-owned hooks instead of styling-class selectors, while the classes remain as presentation markup. +- The shared method-picker dialog’s close/cancel/apply controls use explicit rooted `data-role` hooks, and `openMethodPickerModal(...)` delegates to the component instead of owning dialog rendering, filtering, help display, or selection state. ## Phase 7: Dead Code, Stories, And Public API Cleanup @@ -555,4 +555,3 @@ Exit criteria: - Update this document whenever new legacy work is discovered. - Add unchecked follow-up items immediately when a phase reveals more work. - Treat a feature as incomplete if the componentized shell still delegates core behavior to a legacy control. - diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/index.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/index.js new file mode 100644 index 00000000..d5acef9b --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/index.js @@ -0,0 +1,33 @@ +import { resolveDocumentObj } from '../dom/default-objects.js'; +import { MethodPickerDialogController } from './method-picker-dialog-controller.js'; +import { MethodPickerDialogView } from './method-picker-dialog-view.js'; + +function createMethodPickerDialog({ root, props = {}, services = {}, callbacks = {}, documentObj } = {}) { + const resolvedDocument = resolveDocumentObj(documentObj, root); + const controller = new MethodPickerDialogController({ props, services }); + const view = new MethodPickerDialogView({ + root, + controller, + callbacks, + documentObj: resolvedDocument, + }); + view.mount(); + + return { + update(nextProps = {}) { + controller.updateProps(nextProps); + view.render(); + }, + focusSearch() { + view.focusSearch(); + }, + destroy() { + view.destroy(); + }, + getState() { + return controller.getState(); + }, + }; +} + +export { createMethodPickerDialog }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-controller.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-controller.js new file mode 100644 index 00000000..db294905 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-controller.js @@ -0,0 +1,20 @@ +class MethodHelpDisplayController { + constructor({ props = {} } = {}) { + this.props = { + selectedOption: props.selectedOption || null, + }; + } + + updateProps(nextProps = {}) { + this.props = { + ...this.props, + ...nextProps, + }; + } + + getState() { + return { ...this.props }; + } +} + +export { MethodHelpDisplayController }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js new file mode 100644 index 00000000..c3a7ff8d --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js @@ -0,0 +1,60 @@ +import { + escapeHtml, + getReturnExamples, + getUsageFunctionCalls, + renderExampleList, + renderParameterDetailsTable, + renderParameterTypesTable, + renderUsageExamples, +} from './method-picker-dialog-utils.js'; + +class MethodHelpDisplayView { + constructor({ root, controller } = {}) { + this.root = root; + this.controller = controller; + } + + mount() { + if (!this.root) { + throw new Error('MethodHelpDisplayView requires a root element'); + } + this.root.className = 'method-picker-detail'; + this.root.setAttribute('data-role', 'method-picker-detail'); + this.root.setAttribute('aria-label', 'Method details'); + this.render(); + } + + render() { + const selected = this.controller.getState().selectedOption; + if (!selected) { + this.root.innerHTML = '

    No method selected

    '; + return; + } + const model = selected.helpModel || {}; + const usageExamples = getUsageFunctionCalls(model); + const returnExamples = getReturnExamples(model); + const docsUrl = String(model.docsUrl || '').trim(); + const hasParams = Array.isArray(model.params) && model.params.length > 0; + this.root.innerHTML = ` +

    ${escapeHtml(selected.command)}

    +

    ${escapeHtml(model.summary || 'No summary available.')}

    +

    Schema: ${escapeHtml(model.heading || selected.command)}()

    +
    Parameter Details
    +
    ${renderParameterDetailsTable(model)}
    + ${hasParams ? `
    Parameter Types
    ${renderParameterTypesTable(model)}
    ` : ''} + ${usageExamples.length ? `
    Usage Examples
    ${renderUsageExamples(model, selected.command)}` : ''} + ${usageExamples.length === 0 ? `
    Return Examples
    ${renderExampleList(returnExamples, 'No return examples available')}` : ''} + ${ + docsUrl + ? `` + : '' + } + `; + } + + destroy() { + this.root?.replaceChildren(); + } +} + +export { MethodHelpDisplayView }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display.js new file mode 100644 index 00000000..8d1bc0ab --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display.js @@ -0,0 +1,23 @@ +import { MethodHelpDisplayController } from './method-help-display-controller.js'; +import { MethodHelpDisplayView } from './method-help-display-view.js'; + +function createMethodHelpDisplay({ root, props = {} } = {}) { + const controller = new MethodHelpDisplayController({ props }); + const view = new MethodHelpDisplayView({ root, controller }); + view.mount(); + + return { + update(nextProps = {}) { + controller.updateProps(nextProps); + view.render(); + }, + destroy() { + view.destroy(); + }, + getState() { + return controller.getState(); + }, + }; +} + +export { createMethodHelpDisplay }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-controller.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-controller.js new file mode 100644 index 00000000..60b420b9 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-controller.js @@ -0,0 +1,26 @@ +class MethodListController { + constructor({ props = {} } = {}) { + this.props = { + options: Array.isArray(props.options) ? props.options.slice() : [], + selectedCommand: String(props.selectedCommand || ''), + }; + } + + updateProps(nextProps = {}) { + this.props = { + ...this.props, + ...nextProps, + options: Array.isArray(nextProps.options) ? nextProps.options.slice() : this.props.options.slice(), + selectedCommand: String(nextProps.selectedCommand ?? this.props.selectedCommand ?? ''), + }; + } + + getState() { + return { + ...this.props, + options: this.props.options.slice(), + }; + } +} + +export { MethodListController }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-view.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-view.js new file mode 100644 index 00000000..380a3a39 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list-view.js @@ -0,0 +1,57 @@ +import { escapeHtml } from './method-picker-dialog-utils.js'; + +class MethodListView { + constructor({ root, controller, callbacks = {} } = {}) { + this.root = root; + this.controller = controller; + this.callbacks = callbacks; + this.handleClick = (event) => { + const command = event.target?.closest?.('[data-command]')?.getAttribute?.('data-command'); + if (command) { + this.callbacks.onSelectCommand?.(command); + } + }; + } + + mount() { + if (!this.root) { + throw new Error('MethodListView requires a root element'); + } + this.root.className = 'method-picker-list'; + this.root.setAttribute('data-role', 'method-picker-list'); + this.root.setAttribute('aria-label', 'Methods'); + this.root.addEventListener('click', this.handleClick); + this.render(); + } + + render() { + const state = this.controller.getState(); + if (state.options.length === 0) { + this.root.innerHTML = '

    No methods match the current filter.

    '; + return; + } + this.root.innerHTML = state.options + .map((option) => { + const isSelected = option.command === state.selectedCommand; + return ` + `; + }) + .join(''); + } + + destroy() { + this.root?.removeEventListener('click', this.handleClick); + this.root?.replaceChildren(); + } +} + +export { MethodListView }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list.js new file mode 100644 index 00000000..922d3a87 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-list.js @@ -0,0 +1,23 @@ +import { MethodListController } from './method-list-controller.js'; +import { MethodListView } from './method-list-view.js'; + +function createMethodList({ root, props = {}, callbacks = {} } = {}) { + const controller = new MethodListController({ props }); + const view = new MethodListView({ root, controller, callbacks }); + view.mount(); + + return { + update(nextProps = {}) { + controller.updateProps(nextProps); + view.render(); + }, + destroy() { + view.destroy(); + }, + getState() { + return controller.getState(); + }, + }; +} + +export { createMethodList }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-controller.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-controller.js new file mode 100644 index 00000000..4abe1be3 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-controller.js @@ -0,0 +1,27 @@ +class MethodNavigatorController { + constructor({ props = {} } = {}) { + this.props = { + searchTerm: String(props.searchTerm || ''), + activeTab: props.activeTab || 'all', + tabSpecs: Array.isArray(props.tabSpecs) ? props.tabSpecs.slice() : [], + }; + } + + updateProps(nextProps = {}) { + this.props = { + ...this.props, + ...nextProps, + searchTerm: String(nextProps.searchTerm ?? this.props.searchTerm ?? ''), + tabSpecs: Array.isArray(nextProps.tabSpecs) ? nextProps.tabSpecs.slice() : this.props.tabSpecs.slice(), + }; + } + + getState() { + return { + ...this.props, + tabSpecs: this.props.tabSpecs.slice(), + }; + } +} + +export { MethodNavigatorController }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-view.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-view.js new file mode 100644 index 00000000..01812865 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator-view.js @@ -0,0 +1,73 @@ +import { escapeHtml } from './method-picker-dialog-utils.js'; + +class MethodNavigatorView { + constructor({ root, controller, callbacks = {} } = {}) { + this.root = root; + this.controller = controller; + this.callbacks = callbacks; + this.handleInput = (event) => { + this.callbacks.onSearchTermChange?.(event?.currentTarget?.value || ''); + }; + this.handleTabClick = (event) => { + const tabId = event.target?.closest?.('[data-role="method-picker-tab"]')?.getAttribute?.('data-tab'); + if (tabId) { + this.callbacks.onTabChange?.(tabId); + } + }; + } + + mount() { + if (!this.root) { + throw new Error('MethodNavigatorView requires a root element'); + } + this.root.className = 'method-picker-toolbar'; + this.root.innerHTML = ` + +
    + `; + this.searchInput = this.root.querySelector('[data-role="method-picker-search"]'); + this.tabsElement = this.root.querySelector('[data-role="method-picker-tabs"]'); + this.searchInput?.addEventListener('input', this.handleInput); + this.tabsElement?.addEventListener('click', this.handleTabClick); + this.render(); + } + + render() { + const state = this.controller.getState(); + if (this.searchInput && this.searchInput.value !== state.searchTerm) { + this.searchInput.value = state.searchTerm; + } + if (this.tabsElement) { + this.tabsElement.innerHTML = state.tabSpecs + .map( + (tab) => + `` + ) + .join(''); + } + } + + focusSearch() { + this.searchInput?.focus?.(); + } + + isSearchFocused(documentObj) { + return documentObj?.activeElement === this.searchInput; + } + + destroy() { + this.searchInput?.removeEventListener('input', this.handleInput); + this.tabsElement?.removeEventListener('click', this.handleTabClick); + this.root?.replaceChildren(); + } +} + +export { MethodNavigatorView }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator.js new file mode 100644 index 00000000..df270b20 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-navigator.js @@ -0,0 +1,29 @@ +import { MethodNavigatorController } from './method-navigator-controller.js'; +import { MethodNavigatorView } from './method-navigator-view.js'; + +function createMethodNavigator({ root, props = {}, callbacks = {} } = {}) { + const controller = new MethodNavigatorController({ props }); + const view = new MethodNavigatorView({ root, controller, callbacks }); + view.mount(); + + return { + update(nextProps = {}) { + controller.updateProps(nextProps); + view.render(); + }, + focusSearch() { + view.focusSearch(); + }, + isSearchFocused(documentObj) { + return view.isSearchFocused(documentObj); + }, + destroy() { + view.destroy(); + }, + getState() { + return controller.getState(); + }, + }; +} + +export { createMethodNavigator }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-controller.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-controller.js new file mode 100644 index 00000000..f94004d3 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-controller.js @@ -0,0 +1,121 @@ +import { + buildMethodPickerTabSpecs, + filterMethodPickerOptions, + getNextRecentEntries, + normalizeActiveTab, + normalizeRecentEntries, + prepareMethodPickerOptions, + resolveSelectedCommandForFiltered, + selectInitialCommand, +} from './method-picker-dialog-utils.js'; + +function normalizeProps(props = {}) { + const options = prepareMethodPickerOptions(props.options); + const tabSpecs = buildMethodPickerTabSpecs(options); + const activeTab = normalizeActiveTab(tabSpecs, props.initialTab); + const recentEntries = normalizeRecentEntries(props.recentEntries); + + return { + title: props.title || 'Choose Method', + options, + tabSpecs, + activeTab, + searchTerm: String(props.searchTerm || ''), + selectedCommand: selectInitialCommand(options, props.currentCommand), + recentEntries, + }; +} + +class MethodPickerDialogController { + constructor({ props = {}, services = {} } = {}) { + this.recentStore = services.recentStore || null; + this.state = normalizeProps({ + ...props, + recentEntries: props.recentEntries || this.recentStore?.read?.() || [], + }); + this.syncSelectionWithFiltered(); + } + + getFilteredOptions() { + return filterMethodPickerOptions({ + options: this.state.options, + activeTab: this.state.activeTab, + searchTerm: this.state.searchTerm, + recentEntries: this.state.recentEntries, + }); + } + + syncSelectionWithFiltered() { + const filteredOptions = this.getFilteredOptions(); + this.state.selectedCommand = resolveSelectedCommandForFiltered(this.state.selectedCommand, filteredOptions); + } + + updateProps(nextProps = {}) { + const currentState = this.getState(); + this.state = normalizeProps({ + title: currentState.title, + options: currentState.options, + initialTab: currentState.activeTab, + currentCommand: currentState.selectedCommand, + searchTerm: currentState.searchTerm, + recentEntries: currentState.recentEntries, + ...nextProps, + }); + this.syncSelectionWithFiltered(); + } + + setSearchTerm(searchTerm) { + this.state.searchTerm = String(searchTerm || ''); + this.syncSelectionWithFiltered(); + } + + setActiveTab(activeTab) { + this.state.activeTab = normalizeActiveTab(this.state.tabSpecs, activeTab); + this.syncSelectionWithFiltered(); + } + + selectCommand(command) { + const selected = String(command || '').trim(); + if (this.state.options.some((option) => option.command === selected)) { + this.state.selectedCommand = selected; + } + } + + selectFirstFilteredOption() { + const first = this.getFilteredOptions()[0]; + if (first) { + this.state.selectedCommand = first.command; + } + } + + getSelectedOption() { + return this.state.options.find((option) => option.command === this.state.selectedCommand) || null; + } + + applySelection() { + const selected = this.getSelectedOption(); + if (!selected) { + return null; + } + const recentEntries = getNextRecentEntries(selected.command, this.state.recentEntries); + this.state.recentEntries = recentEntries; + this.recentStore?.write?.(recentEntries); + return { sourceType: selected.sourceType, command: selected.command }; + } + + getState() { + const filteredOptions = this.getFilteredOptions(); + const selectedOption = this.getSelectedOption(); + return { + ...this.state, + options: this.state.options.slice(), + tabSpecs: this.state.tabSpecs.slice(), + recentEntries: this.state.recentEntries.slice(), + filteredOptions, + selectedOption, + applyDisabled: !selectedOption, + }; + } +} + +export { MethodPickerDialogController }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js new file mode 100644 index 00000000..f5fa74b2 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js @@ -0,0 +1,317 @@ +const RECENT_STORAGE_KEY = 'anywaydata.method-picker.recent'; +const MAX_RECENT = 8; +const CORE_COMMANDS = new Set(['enum', 'literal', 'regex']); + +function escapeHtml(text) { + return String(text ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function normalizeRecentEntries(entries) { + return Array.isArray(entries) ? entries.map((entry) => String(entry || '').trim()).filter(Boolean) : []; +} + +function readRecent(windowObj) { + try { + const value = windowObj?.localStorage?.getItem?.(RECENT_STORAGE_KEY); + if (!value) { + return []; + } + return normalizeRecentEntries(JSON.parse(value)); + } catch { + return []; + } +} + +function writeRecent(windowObj, entries) { + try { + windowObj?.localStorage?.setItem?.( + RECENT_STORAGE_KEY, + JSON.stringify(normalizeRecentEntries(entries).slice(0, MAX_RECENT)) + ); + } catch { + // ignore storage failures + } +} + +function createMethodPickerRecentStore(windowObj) { + return { + read: () => readRecent(windowObj), + write: (entries) => writeRecent(windowObj, entries), + }; +} + +function getNextRecentEntries(command, recentEntries = []) { + const selectedCommand = String(command || '').trim(); + if (!selectedCommand) { + return normalizeRecentEntries(recentEntries).slice(0, MAX_RECENT); + } + return [selectedCommand, ...normalizeRecentEntries(recentEntries).filter((item) => item !== selectedCommand)].slice( + 0, + MAX_RECENT + ); +} + +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 normalizeReturnExampleValue(value) { + if (typeof value === 'bigint') { + return `${value}n`; + } + if (Array.isArray(value)) { + return JSON.stringify(value); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (value && typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +} + +function getUsageFunctionCalls(model) { + return (Array.isArray(model?.usageExamples) ? model.usageExamples : []) + .map((usageExample) => String(usageExample?.functionCall || '').trim()) + .filter(Boolean); +} + +function getReturnExamples(model) { + const unique = new Set(); + const usageExamples = Array.isArray(model?.usageExamples) ? model.usageExamples : []; + usageExamples.forEach((usageExample) => { + if (Object.prototype.hasOwnProperty.call(usageExample || {}, 'sampleReturnValue')) { + unique.add(normalizeReturnExampleValue(usageExample.sampleReturnValue).trim()); + } + }); + toExampleList(model?.returnExamples).forEach((entry) => unique.add(entry)); + return [...unique].filter(Boolean); +} + +function buildSearchText(option) { + const params = Array.isArray(option?.helpModel?.params) ? option.helpModel.params.map((p) => p?.name || '') : []; + const usageExamples = getUsageFunctionCalls(option?.helpModel); + const returnExamples = getReturnExamples(option?.helpModel); + return [ + option?.command || '', + option?.helpModel?.summary || '', + usageExamples.join(' '), + returnExamples.join(' '), + params.join(' '), + ].join(' '); +} + +function prepareMethodPickerOptions(options = []) { + return (Array.isArray(options) ? options : []).map((option) => ({ + ...option, + command: String(option?.command || '').trim(), + sourceType: String(option?.sourceType || '').trim(), + searchText: buildSearchText(option).toLowerCase(), + })); +} + +function buildMethodPickerTabSpecs(options = []) { + const domainCategories = [ + ...new Set( + options + .filter((option) => option.sourceType === 'domain' && String(option.command || '').includes('.')) + .map((option) => String(option.command).split('.')[0]) + ), + ].sort((left, right) => left.localeCompare(right)); + + return [ + { id: 'all', label: 'All' }, + { id: 'core', label: 'Core' }, + ...domainCategories.map((category) => ({ id: `domain:${category}`, label: category })), + { id: 'faker', label: 'Faker' }, + { id: 'recent', label: 'Recently used' }, + ]; +} + +function normalizeActiveTab(tabSpecs, initialTab = '') { + return tabSpecs.some((tab) => tab.id === initialTab) ? initialTab : 'all'; +} + +function filterMethodPickerOptions({ options = [], activeTab = 'all', searchTerm = '', recentEntries = [] } = {}) { + const normalizedSearch = String(searchTerm || '') + .trim() + .toLowerCase(); + const recent = normalizeRecentEntries(recentEntries); + + return options.filter((option) => { + const command = String(option.command || ''); + if (activeTab === 'core' && !CORE_COMMANDS.has(command.toLowerCase())) { + return false; + } + if (activeTab === 'faker' && option.sourceType !== 'faker') { + return false; + } + if (activeTab === 'recent' && !recent.includes(command)) { + return false; + } + if (activeTab.startsWith('domain:')) { + const category = activeTab.split(':')[1] || ''; + if (option.sourceType !== 'domain' || !command.startsWith(`${category}.`)) { + return false; + } + } + return !normalizedSearch || String(option.searchText || '').includes(normalizedSearch); + }); +} + +function selectInitialCommand(options = [], currentCommand = '') { + const command = String(currentCommand || '').trim(); + return options.some((option) => option.command === command) ? command : options[0]?.command || ''; +} + +function resolveSelectedCommandForFiltered(selectedCommand = '', filteredOptions = []) { + return filteredOptions.some((option) => option.command === selectedCommand) + ? selectedCommand + : filteredOptions[0]?.command || ''; +} + +function splitFunctionCall(functionCall, command = '') { + const text = String(functionCall || '').trim(); + const normalizedCommand = String(command || '').trim(); + if (!text) { + return { command: normalizedCommand, params: '' }; + } + if (normalizedCommand && text === normalizedCommand) { + return { command: normalizedCommand, params: '' }; + } + if (normalizedCommand && text.startsWith(`${normalizedCommand}(`) && text.endsWith(')')) { + return { command: normalizedCommand, params: text.slice(normalizedCommand.length) }; + } + + const openParenIndex = text.indexOf('('); + if (openParenIndex > 0 && text.endsWith(')')) { + return { + command: text.slice(0, openParenIndex).trim(), + params: text.slice(openParenIndex).trim(), + }; + } + + return { command: normalizedCommand || text, params: '' }; +} + +function formatParamsFieldValue(command, params) { + const trimmedCommand = String(command || '') + .trim() + .toLowerCase(); + const trimmedParams = String(params || '').trim(); + if (!trimmedParams || trimmedParams === '()') { + return 'Leave blank'; + } + if (trimmedCommand === 'datatype.enum' && trimmedParams.startsWith('(') && trimmedParams.endsWith(')')) { + return trimmedParams.slice(1, -1).trim(); + } + return trimmedParams; +} + +function renderParameterDetailsTable(model) { + if (!Array.isArray(model?.params) || model.params.length === 0) { + return '

    No params

    '; + } + const rows = model.params + .map((param) => { + const description = String(param.description || '').trim(); + const example = String(param.example || param.examples || '').trim(); + const details = [description, example ? `Example: ${example}` : ''].filter(Boolean).join(' '); + return `${escapeHtml(param.name)}${escapeHtml(details || '-')}`; + }) + .join(''); + return `${rows}
    NameDetails
    `; +} + +function renderParameterTypesTable(model) { + if (!Array.isArray(model?.params) || model.params.length === 0) { + return '

    No params

    '; + } + const rows = model.params + .map((param) => { + const optional = param.optional ? 'optional' : 'required'; + return `${escapeHtml(param.name)}${escapeHtml( + param.type || 'unknown' + )}${optional}`; + }) + .join(''); + return `${rows}
    NameTypeReq
    `; +} + +function renderExampleList(examples, emptyText) { + if (!examples.length) { + return `

    ${escapeHtml(emptyText)}

    `; + } + const rows = examples.map((example) => `
  • ${escapeHtml(example)}
  • `).join(''); + return ``; +} + +function renderUsageExamples(model, selectedCommand) { + const usageExamples = Array.isArray(model?.usageExamples) ? model.usageExamples : []; + if (usageExamples.length === 0) { + return ''; + } + + return usageExamples + .map((usageExample) => { + const functionCall = String(usageExample?.functionCall || '').trim(); + if (!functionCall) { + return ''; + } + const description = String(usageExample?.description || '').trim(); + const split = splitFunctionCall(functionCall, selectedCommand); + const paramsFieldValue = formatParamsFieldValue(split.command, split.params); + const hasSampleReturnValue = Object.prototype.hasOwnProperty.call(usageExample || {}, 'sampleReturnValue'); + const sampleReturnValue = hasSampleReturnValue + ? normalizeReturnExampleValue(usageExample.sampleReturnValue).trim() + : ''; + + return ` +
    + ${description ? `

    ${escapeHtml(description)}

    ` : ''} +

    Command: ${escapeHtml(split.command || selectedCommand || '')}

    +

    Params field: ${escapeHtml(paramsFieldValue)}

    +

    Full call: ${escapeHtml(functionCall)}

    + ${sampleReturnValue ? `

    Returns: ${escapeHtml(sampleReturnValue)}

    ` : ''} +
    + `; + }) + .join(''); +} + +export { + CORE_COMMANDS, + MAX_RECENT, + RECENT_STORAGE_KEY, + buildMethodPickerTabSpecs, + buildSearchText, + createMethodPickerRecentStore, + escapeHtml, + filterMethodPickerOptions, + formatParamsFieldValue, + getNextRecentEntries, + getReturnExamples, + getUsageFunctionCalls, + normalizeActiveTab, + normalizeRecentEntries, + prepareMethodPickerOptions, + readRecent, + renderExampleList, + renderParameterDetailsTable, + renderParameterTypesTable, + renderUsageExamples, + resolveSelectedCommandForFiltered, + selectInitialCommand, + splitFunctionCall, + writeRecent, +}; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js new file mode 100644 index 00000000..12963c16 --- /dev/null +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js @@ -0,0 +1,164 @@ +import { createMethodHelpDisplay } from './method-help-display.js'; +import { createMethodList } from './method-list.js'; +import { createMethodNavigator } from './method-navigator.js'; +import { escapeHtml } from './method-picker-dialog-utils.js'; + +class MethodPickerDialogView { + constructor({ root, controller, callbacks = {}, documentObj } = {}) { + this.root = root; + this.controller = controller; + this.callbacks = callbacks; + this.documentObj = documentObj; + this.handleOverlayClick = (event) => this.onOverlayClick(event); + this.handleKeyDown = (event) => this.onKeyDown(event); + } + + mount() { + if (!this.root) { + throw new Error('MethodPickerDialogView requires a root element'); + } + const state = this.controller.getState(); + this.root.className = 'method-picker-overlay'; + this.root.setAttribute('data-role', 'method-picker-overlay'); + this.root.innerHTML = ` + + `; + + this.navigator = createMethodNavigator({ + root: this.root.querySelector('[data-role="method-picker-navigator-root"]'), + props: this.getNavigatorProps(), + callbacks: { + onSearchTermChange: (searchTerm) => { + this.controller.setSearchTerm(searchTerm); + this.render(); + }, + onTabChange: (tabId) => { + this.controller.setActiveTab(tabId); + this.render(); + }, + }, + }); + this.list = createMethodList({ + root: this.root.querySelector('[data-role="method-picker-list-root"]'), + props: this.getListProps(), + callbacks: { + onSelectCommand: (command) => { + this.controller.selectCommand(command); + this.render(); + }, + }, + }); + this.helpDisplay = createMethodHelpDisplay({ + root: this.root.querySelector('[data-role="method-picker-detail-root"]'), + props: this.getHelpDisplayProps(), + }); + this.applyButton = this.root.querySelector('[data-role="method-picker-apply-button"]'); + this.root.addEventListener('click', this.handleOverlayClick); + this.root.addEventListener('keydown', this.handleKeyDown); + this.render(); + } + + getNavigatorProps() { + const state = this.controller.getState(); + return { + activeTab: state.activeTab, + searchTerm: state.searchTerm, + tabSpecs: state.tabSpecs, + }; + } + + getListProps() { + const state = this.controller.getState(); + return { + options: state.filteredOptions, + selectedCommand: state.selectedCommand, + }; + } + + getHelpDisplayProps() { + return { + selectedOption: this.controller.getState().selectedOption, + }; + } + + render() { + const state = this.controller.getState(); + this.navigator?.update(this.getNavigatorProps()); + this.list?.update(this.getListProps()); + this.helpDisplay?.update(this.getHelpDisplayProps()); + if (this.applyButton) { + this.applyButton.disabled = state.applyDisabled; + this.applyButton.setAttribute('aria-disabled', state.applyDisabled ? 'true' : 'false'); + } + } + + onOverlayClick(event) { + if (event.target === this.root) { + this.callbacks.onCancel?.({ reason: 'backdrop' }); + return; + } + if (event.target?.closest?.('[data-role="method-picker-close-button"]')) { + this.callbacks.onCancel?.({ reason: 'close' }); + return; + } + if (event.target?.closest?.('[data-role="method-picker-cancel-button"]')) { + this.callbacks.onCancel?.({ reason: 'cancel' }); + return; + } + if (event.target?.closest?.('[data-role="method-picker-apply-button"]')) { + const selection = this.controller.applySelection(); + if (selection) { + this.callbacks.onApply?.(selection); + } + } + } + + onKeyDown(event) { + if (event.key === 'Escape') { + event.preventDefault(); + this.callbacks.onCancel?.({ reason: 'escape' }); + return; + } + if (event.key === '/' && !this.navigator?.isSearchFocused(this.documentObj)) { + event.preventDefault(); + this.navigator?.focusSearch(); + return; + } + if (event.key === 'Enter' && this.navigator?.isSearchFocused(this.documentObj)) { + event.preventDefault(); + this.controller.selectFirstFilteredOption(); + this.render(); + } + } + + focusSearch() { + this.navigator?.focusSearch(); + } + + destroy() { + this.root?.removeEventListener('click', this.handleOverlayClick); + this.root?.removeEventListener('keydown', this.handleKeyDown); + this.navigator?.destroy(); + this.list?.destroy(); + this.helpDisplay?.destroy(); + this.root?.replaceChildren(); + } +} + +export { MethodPickerDialogView }; 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 98c136f7..e7b8997b 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 @@ -1,125 +1,9 @@ import { getDefaultDocumentObj, getDefaultWindowObj, resolveWindowObj } from '../../dom/default-objects.js'; +import { createMethodPickerDialog } from '../../method-picker-dialog/index.js'; +import { createMethodPickerRecentStore } from '../../method-picker-dialog/method-picker-dialog-utils.js'; -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) { - return String(text ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function readRecent(windowObj) { - try { - const value = windowObj?.localStorage?.getItem?.(RECENT_STORAGE_KEY); - if (!value) { - return []; - } - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function writeRecent(windowObj, entries) { - try { - windowObj?.localStorage?.setItem?.(RECENT_STORAGE_KEY, JSON.stringify(entries.slice(0, MAX_RECENT))); - } catch { - // ignore - } -} - -function buildSearchText(option) { - const params = Array.isArray(option?.helpModel?.params) ? option.helpModel.params.map((p) => p?.name || '') : []; - const usageExamples = getUsageFunctionCalls(option?.helpModel); - const returnExamples = getReturnExamples(option?.helpModel); - return [ - option.command, - option.helpModel?.summary || '', - usageExamples.join(' '), - returnExamples.join(' '), - params.join(' '), - ].join(' '); -} - -function getUsageFunctionCalls(model) { - return (Array.isArray(model?.usageExamples) ? model.usageExamples : []) - .map((usageExample) => String(usageExample?.functionCall || '').trim()) - .filter(Boolean); -} - -function splitFunctionCall(functionCall, command = '') { - const text = String(functionCall || '').trim(); - const normalizedCommand = String(command || '').trim(); - if (!text) { - return { command: normalizedCommand, params: '' }; - } - if (normalizedCommand && text === normalizedCommand) { - return { command: normalizedCommand, params: '' }; - } - if (normalizedCommand && text.startsWith(`${normalizedCommand}(`) && text.endsWith(')')) { - return { command: normalizedCommand, params: text.slice(normalizedCommand.length) }; - } - - const openParenIndex = text.indexOf('('); - if (openParenIndex > 0 && text.endsWith(')')) { - return { - command: text.slice(0, openParenIndex).trim(), - params: text.slice(openParenIndex).trim(), - }; - } - - return { command: normalizedCommand || text, params: '' }; -} - -function formatParamsFieldValue(command, params) { - const trimmedCommand = String(command || '') - .trim() - .toLowerCase(); - const trimmedParams = String(params || '').trim(); - if (!trimmedParams || trimmedParams === '()') { - return 'Leave blank'; - } - if (trimmedCommand === 'datatype.enum' && trimmedParams.startsWith('(') && trimmedParams.endsWith(')')) { - return trimmedParams.slice(1, -1).trim(); - } - return trimmedParams; -} - -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 normalizeReturnExampleValue(value) { - if (typeof value === 'bigint') { - return `${value}n`; - } - - if (Array.isArray(value)) { - return JSON.stringify(value); - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (value && typeof value === 'object') { - return JSON.stringify(value); - } - - return String(value); -} function ensureCriticalStyles(documentObj) { if (!documentObj?.head || documentObj.getElementById(CRITICAL_STYLE_ID)) { @@ -201,87 +85,14 @@ function ensureStyles(documentObj) { documentObj.head.appendChild(link); } -function renderParameterDetailsTable(model) { - if (!Array.isArray(model?.params) || model.params.length === 0) { - return '

    No params

    '; - } - const rows = model.params - .map((param) => { - const description = String(param.description || '').trim(); - const example = String(param.example || param.examples || '').trim(); - const details = [description, example ? `Example: ${example}` : ''].filter(Boolean).join(' '); - return `${escapeHtml(param.name)}${escapeHtml(details || '-')}`; - }) - .join(''); - return `${rows}
    NameDetails
    `; -} - -function renderParameterTypesTable(model) { - if (!Array.isArray(model?.params) || model.params.length === 0) { - return '

    No params

    '; - } - const rows = model.params - .map((param) => { - const optional = param.optional ? 'optional' : 'required'; - return `${escapeHtml(param.name)}${escapeHtml( - param.type || 'unknown' - )}${optional}`; - }) - .join(''); - return `${rows}
    NameTypeReq
    `; -} - -function renderExampleList(examples, emptyText) { - if (!examples.length) { - return `

    ${escapeHtml(emptyText)}

    `; +function restoreFocus(documentObj, previouslyFocusedElement) { + if ( + previouslyFocusedElement && + previouslyFocusedElement !== documentObj.body && + documentObj.contains?.(previouslyFocusedElement) + ) { + previouslyFocusedElement.focus?.(); } - const rows = examples.map((example) => `
  • ${escapeHtml(example)}
  • `).join(''); - return ``; -} - -function renderUsageExamples(model, selectedCommand) { - const usageExamples = Array.isArray(model?.usageExamples) ? model.usageExamples : []; - if (usageExamples.length === 0) { - return ''; - } - - return usageExamples - .map((usageExample) => { - const functionCall = String(usageExample?.functionCall || '').trim(); - if (!functionCall) { - return ''; - } - const description = String(usageExample?.description || '').trim(); - const split = splitFunctionCall(functionCall, selectedCommand); - const paramsFieldValue = formatParamsFieldValue(split.command, split.params); - const hasSampleReturnValue = Object.prototype.hasOwnProperty.call(usageExample || {}, 'sampleReturnValue'); - const sampleReturnValue = hasSampleReturnValue - ? normalizeReturnExampleValue(usageExample.sampleReturnValue).trim() - : ''; - - return ` -
    - ${description ? `

    ${escapeHtml(description)}

    ` : ''} -

    Command: ${escapeHtml(split.command || selectedCommand || '')}

    -

    Params field: ${escapeHtml(paramsFieldValue)}

    -

    Full call: ${escapeHtml(functionCall)}

    - ${sampleReturnValue ? `

    Returns: ${escapeHtml(sampleReturnValue)}

    ` : ''} -
    - `; - }) - .join(''); -} - -function getReturnExamples(model) { - const unique = new Set(); - const usageExamples = Array.isArray(model?.usageExamples) ? model.usageExamples : []; - usageExamples.forEach((usageExample) => { - if (Object.prototype.hasOwnProperty.call(usageExample || {}, 'sampleReturnValue')) { - unique.add(normalizeReturnExampleValue(usageExample.sampleReturnValue).trim()); - } - }); - toExampleList(model?.returnExamples).forEach((entry) => unique.add(entry)); - return [...unique].filter(Boolean); } function openMethodPickerModal({ @@ -295,252 +106,49 @@ function openMethodPickerModal({ if (!documentObj?.body || typeof documentObj.createElement !== 'function') { return Promise.resolve(null); } - windowObj = resolveWindowObj(windowObj, documentObj); + + const resolvedWindow = resolveWindowObj(windowObj, documentObj); ensureCriticalStyles(documentObj); ensureStyles(documentObj); + const previouslyFocusedElement = documentObj.activeElement; const overlay = documentObj.createElement('div'); - overlay.className = 'method-picker-overlay'; - overlay.setAttribute('data-role', 'method-picker-overlay'); - overlay.innerHTML = ` - - `; - - const searchInput = overlay.querySelector('[data-role="method-picker-search"]'); - const listElem = overlay.querySelector('[data-role="method-picker-list"]'); - const detailElem = overlay.querySelector('[data-role="method-picker-detail"]'); - const tabsElem = overlay.querySelector('[data-role="method-picker-tabs"]'); - const applyButton = overlay.querySelector('[data-role="method-picker-apply-button"]'); - - const recent = readRecent(windowObj); - const prepared = (Array.isArray(options) ? options : []).map((option) => ({ - ...option, - searchText: buildSearchText(option).toLowerCase(), - })); - const domainCategories = [ - ...new Set( - prepared - .filter((option) => option.sourceType === 'domain' && String(option.command || '').includes('.')) - .map((option) => String(option.command).split('.')[0]) - ), - ].sort((left, right) => left.localeCompare(right)); - const tabSpecs = [ - { id: 'all', label: 'All' }, - { id: 'core', label: 'Core' }, - ...domainCategories.map((category) => ({ id: `domain:${category}`, label: category })), - { id: 'faker', label: 'Faker' }, - { id: 'recent', label: 'Recently used' }, - ]; - let activeTab = tabSpecs.some((tab) => tab.id === initialTab) ? initialTab : 'all'; - let selectedCommand = prepared.some((o) => o.command === currentCommand) - ? currentCommand - : prepared[0]?.command || ''; - let tabButtons = []; - - function syncApplyButtonState() { - applyButton.disabled = !selectedCommand; - applyButton.setAttribute('aria-disabled', applyButton.disabled ? 'true' : 'false'); - } - - function renderTabs() { - tabsElem.innerHTML = tabSpecs - .map( - (tab) => - `` - ) - .join(''); - tabButtons = Array.from(tabsElem.querySelectorAll('[data-role="method-picker-tab"]')); - tabButtons.forEach((button) => { - const tabId = button.getAttribute('data-tab') || ''; - button.classList.toggle('is-active', tabId === activeTab); - button.addEventListener('click', () => { - activeTab = tabId || 'all'; - tabButtons.forEach((entry) => entry.classList.toggle('is-active', entry === button)); - renderList(); - }); - }); - } - - function getSelected() { - return prepared.find((option) => option.command === selectedCommand); - } - - function renderDetail() { - const selected = getSelected(); - if (!selected) { - detailElem.innerHTML = '

    No method selected

    '; - return; - } - const model = selected.helpModel || {}; - const usageExamples = getUsageFunctionCalls(model); - const returnExamples = getReturnExamples(model); - const docsUrl = String(model.docsUrl || '').trim(); - const hasParams = Array.isArray(model.params) && model.params.length > 0; - detailElem.innerHTML = ` -

    ${escapeHtml(selected.command)}

    -

    ${escapeHtml(model.summary || 'No summary available.')}

    -

    Schema: ${escapeHtml(model.heading || selected.command)}()

    -
    Parameter Details
    -
    ${renderParameterDetailsTable(model)}
    - ${hasParams ? `
    Parameter Types
    ${renderParameterTypesTable(model)}
    ` : ''} - ${usageExamples.length ? `
    Usage Examples
    ${renderUsageExamples(model, selected.command)}` : ''} - ${usageExamples.length === 0 ? `
    Return Examples
    ${renderExampleList(returnExamples, 'No return examples available')}` : ''} - ${ - docsUrl - ? `` - : '' - } - `; - } - - function getFiltered() { - const search = String(searchInput.value || '') - .trim() - .toLowerCase(); - return prepared.filter((option) => { - if (activeTab === 'core' && !CORE_COMMANDS.has(String(option.command || '').toLowerCase())) { - return false; - } - if (activeTab === 'faker' && option.sourceType !== 'faker') { - return false; - } - if (activeTab === 'recent' && !recent.includes(option.command)) { - return false; - } - if (activeTab.startsWith('domain:')) { - if (option.sourceType !== 'domain') { - return false; - } - const category = activeTab.split(':')[1] || ''; - if (!String(option.command || '').startsWith(`${category}.`)) { - return false; - } - } - if (!search) { - return true; - } - return option.searchText.includes(search); - }); - } - - function renderList() { - const filtered = getFiltered(); - if (!filtered.some((option) => option.command === selectedCommand)) { - selectedCommand = filtered[0]?.command || ''; - } - listElem.innerHTML = filtered - .map((option) => { - const isSelected = option.command === selectedCommand; - return ` - `; - }) - .join(''); - renderDetail(); - syncApplyButtonState(); - } + documentObj.body.appendChild(overlay); return new Promise((resolve) => { - function restorePreviousFocus() { - if ( - previouslyFocusedElement && - previouslyFocusedElement !== documentObj.body && - documentObj.contains?.(previouslyFocusedElement) - ) { - previouslyFocusedElement.focus?.(); - } - } + let resolved = false; + let component = null; function close(result) { + if (resolved) { + return; + } + resolved = true; + component?.destroy?.(); overlay.remove(); - restorePreviousFocus(); + restoreFocus(documentObj, previouslyFocusedElement); resolve(result || null); } - overlay.addEventListener('click', (event) => { - const closeButton = event.target?.closest?.('[data-role="method-picker-close-button"]'); - const cancelButton = event.target?.closest?.('[data-role="method-picker-cancel-button"]'); - const applyActionButton = event.target?.closest?.('[data-role="method-picker-apply-button"]'); - if (event.target === overlay || closeButton || cancelButton) { - close(null); - return; - } - if (applyActionButton) { - const selected = getSelected(); - if (!selected) { - return; - } - const nextRecent = [selected.command, ...recent.filter((item) => item !== selected.command)]; - writeRecent(windowObj, nextRecent); - close({ sourceType: selected.sourceType, command: selected.command }); - return; - } - const command = event.target?.closest?.('[data-command]')?.getAttribute?.('data-command'); - if (command) { - selectedCommand = command; - renderList(); - } - }); - - overlay.addEventListener('keydown', (event) => { - if (event.key === 'Escape') { - event.preventDefault(); - close(null); - } - if (event.key === '/' && documentObj.activeElement !== searchInput) { - event.preventDefault(); - searchInput.focus(); - } - if (event.key === 'Enter' && documentObj.activeElement === searchInput) { - event.preventDefault(); - const first = getFiltered()[0]; - if (first) { - selectedCommand = first.command; - renderList(); - } - } + component = createMethodPickerDialog({ + root: overlay, + documentObj, + props: { + title, + options, + currentCommand, + initialTab, + }, + services: { + recentStore: createMethodPickerRecentStore(resolvedWindow), + }, + callbacks: { + onApply: (selection) => close(selection), + onCancel: () => close(null), + }, }); - searchInput.addEventListener('input', () => renderList()); - documentObj.body.appendChild(overlay); - renderTabs(); - renderList(); - searchInput.focus(); + component.focusSearch(); }); } diff --git a/packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js b/packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js new file mode 100644 index 00000000..3b59a634 --- /dev/null +++ b/packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js @@ -0,0 +1,124 @@ +import { JSDOM } from 'jsdom'; +import { createMethodHelpDisplay } from '../../../js/gui_components/shared/method-picker-dialog/method-help-display.js'; +import { createMethodList } from '../../../js/gui_components/shared/method-picker-dialog/method-list.js'; +import { createMethodNavigator } from '../../../js/gui_components/shared/method-picker-dialog/method-navigator.js'; + +describe('method picker dialog subcomponents', () => { + let dom; + let root; + + beforeEach(() => { + dom = new JSDOM(''); + global.document = dom.window.document; + root = dom.window.document.createElement('section'); + dom.window.document.body.appendChild(root); + }); + + afterEach(() => { + dom.window.close(); + delete global.document; + }); + + test('navigator renders search and tabs and emits user changes', () => { + const events = []; + const component = createMethodNavigator({ + root, + props: { + searchTerm: '', + activeTab: 'all', + tabSpecs: [ + { id: 'all', label: 'All' }, + { id: 'core', label: 'Core' }, + ], + }, + callbacks: { + onSearchTermChange: (value) => events.push(['search', value]), + onTabChange: (value) => events.push(['tab', value]), + }, + }); + + const search = root.querySelector('[data-role="method-picker-search"]'); + search.value = 'city'; + search.dispatchEvent(new dom.window.Event('input', { bubbles: true })); + root.querySelector('[data-tab="core"]').click(); + + expect(events).toEqual([ + ['search', 'city'], + ['tab', 'core'], + ]); + expect(root.querySelector('[data-tab="all"]').classList.contains('is-active')).toBe(true); + + component.update({ activeTab: 'core', searchTerm: 'city' }); + expect(root.querySelector('[data-tab="core"]').classList.contains('is-active')).toBe(true); + + component.destroy(); + expect(root.children).toHaveLength(0); + }); + + test('list renders selected methods, empty state, and selection callbacks', () => { + const selections = []; + const component = createMethodList({ + root, + props: { + selectedCommand: 'location.city', + options: [ + { sourceType: 'domain', command: 'location.city', helpModel: { summary: 'City' } }, + { sourceType: 'faker', command: 'helpers.arrayElement', helpModel: { summary: 'Pick' } }, + ], + }, + callbacks: { + onSelectCommand: (command) => selections.push(command), + }, + }); + + expect(root.querySelector('[data-command="location.city"]').classList.contains('is-selected')).toBe(true); + root.querySelector('[data-command="helpers.arrayElement"]').click(); + expect(selections).toEqual(['helpers.arrayElement']); + + component.update({ options: [], selectedCommand: '' }); + expect(root.textContent).toContain('No methods match the current filter.'); + + component.destroy(); + expect(root.children).toHaveLength(0); + }); + + test('help display renders params, usage examples, return values, and docs links', () => { + const component = createMethodHelpDisplay({ + root, + props: { + selectedOption: { + sourceType: 'domain', + command: 'datatype.enum', + helpModel: { + heading: 'datatype.enum', + summary: 'Enum helper', + docsUrl: 'https://anywaydata.com/docs/category/generating-data', + params: [{ name: 'csv', type: 'string', optional: true, description: 'CSV values.', example: 'a,b' }], + usageExamples: [ + { + functionCall: 'datatype.enum(csv="active,inactive")', + sampleReturnValue: 'active', + description: 'Choose one value.', + }, + ], + }, + }, + }, + }); + + expect(root.textContent).toContain('datatype.enum'); + expect(root.textContent).toContain('Parameter Details'); + expect(root.textContent).toContain('CSV values.'); + expect(root.textContent).toContain('Parameter Types'); + expect(root.textContent).toContain('optional'); + expect(root.textContent).toContain('Params field: csv="active,inactive"'); + expect(root.textContent).toContain('Returns: active'); + expect(root.innerHTML).not.toContain('
    Return Examples
    '); + expect(root.querySelector('.method-picker-docs-link a').getAttribute('href')).toBe( + 'https://anywaydata.com/docs/category/generating-data' + ); + + component.update({ selectedOption: null }); + expect(root.textContent).toContain('No method selected'); + }); +}); diff --git a/packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js b/packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js new file mode 100644 index 00000000..ed639a32 --- /dev/null +++ b/packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js @@ -0,0 +1,107 @@ +import { MethodPickerDialogController } from '../../../js/gui_components/shared/method-picker-dialog/method-picker-dialog-controller.js'; + +const OPTIONS = [ + { sourceType: 'enum', command: 'enum', helpModel: { summary: 'Enum values', params: [] } }, + { sourceType: 'literal', command: 'literal', helpModel: { summary: 'Literal value', params: [] } }, + { sourceType: 'regex', command: 'regex', helpModel: { summary: 'Regex value', params: [] } }, + { + sourceType: 'domain', + command: 'location.city', + helpModel: { + summary: 'Generate city values', + params: [{ name: 'locale' }], + usageExamples: [{ functionCall: 'location.city(locale="en")', sampleReturnValue: 'London' }], + }, + }, + { + sourceType: 'domain', + command: 'commerce.price', + helpModel: { summary: 'Generate prices', params: [] }, + }, + { + sourceType: 'faker', + command: 'helpers.arrayElement', + helpModel: { summary: 'Pick one value', params: [] }, + }, +]; + +describe('method picker dialog controller', () => { + test('builds tabs from core, domain categories, faker, and recent', () => { + const controller = new MethodPickerDialogController({ props: { options: OPTIONS } }); + + expect(controller.getState().tabSpecs.map((tab) => tab.id)).toEqual([ + 'all', + 'core', + 'domain:commerce', + 'domain:location', + 'faker', + 'recent', + ]); + }); + + test('filters by core, faker, domain category, recent, and search text', () => { + const controller = new MethodPickerDialogController({ + props: { + options: OPTIONS, + recentEntries: ['commerce.price'], + }, + }); + + controller.setActiveTab('core'); + expect(controller.getState().filteredOptions.map((option) => option.command)).toEqual(['enum', 'literal', 'regex']); + + controller.setActiveTab('faker'); + expect(controller.getState().filteredOptions.map((option) => option.command)).toEqual(['helpers.arrayElement']); + + controller.setActiveTab('domain:location'); + expect(controller.getState().filteredOptions.map((option) => option.command)).toEqual(['location.city']); + + controller.setActiveTab('recent'); + expect(controller.getState().filteredOptions.map((option) => option.command)).toEqual(['commerce.price']); + + controller.setActiveTab('all'); + controller.setSearchTerm('locale'); + expect(controller.getState().filteredOptions.map((option) => option.command)).toEqual(['location.city']); + }); + + test('respects initial selection and disables apply for empty options', () => { + const controller = new MethodPickerDialogController({ + props: { + options: OPTIONS, + currentCommand: 'commerce.price', + initialTab: 'domain:commerce', + }, + }); + + expect(controller.getState().activeTab).toBe('domain:commerce'); + expect(controller.getState().selectedCommand).toBe('commerce.price'); + expect(controller.getState().applyDisabled).toBe(false); + + const emptyController = new MethodPickerDialogController({ props: { options: [] } }); + expect(emptyController.getState().selectedCommand).toBe(''); + expect(emptyController.getState().applyDisabled).toBe(true); + }); + + test('selects first filtered option from search and records recent entries on apply', () => { + const written = []; + const controller = new MethodPickerDialogController({ + props: { + options: OPTIONS, + recentEntries: ['helpers.arrayElement'], + }, + services: { + recentStore: { + read: () => ['helpers.arrayElement'], + write: (entries) => written.push(entries), + }, + }, + }); + + controller.setSearchTerm('price'); + controller.selectFirstFilteredOption(); + + expect(controller.getState().selectedCommand).toBe('commerce.price'); + expect(controller.applySelection()).toEqual({ sourceType: 'domain', command: 'commerce.price' }); + expect(written).toEqual([['commerce.price', 'helpers.arrayElement']]); + }); +}); 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 61a28e6f..d48f342f 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 @@ -67,6 +67,56 @@ describe('method picker modal', () => { expect(result).toBeNull(); }); + test('focuses search with slash and selects the first filtered method with enter', async () => { + const promise = openMethodPickerModal({ + documentObj: document, + windowObj: window, + options: [ + { + sourceType: 'domain', + command: 'commerce.price', + helpModel: { summary: 'Generate prices', params: [], example: '' }, + }, + { + sourceType: 'domain', + command: 'location.city', + helpModel: { summary: 'Generate city values', params: [], example: '' }, + }, + ], + currentCommand: '', + }); + + getSearchInput().blur(); + getOverlay().dispatchEvent(new window.KeyboardEvent('keydown', { key: '/', bubbles: true })); + expect(document.activeElement).toBe(getSearchInput()); + + const search = getSearchInput(); + search.value = 'city'; + search.dispatchEvent(new window.Event('input', { bubbles: true })); + search.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(getOverlay().querySelector('[data-role="method-picker-tile"].is-selected').textContent).toContain( + 'location.city' + ); + + getOverlay().querySelector('[data-role="method-picker-apply-button"]').click(); + await expect(promise).resolves.toEqual({ sourceType: 'domain', command: 'location.city' }); + }); + + test('cancels on backdrop click and removes the overlay', async () => { + const promise = openMethodPickerModal({ + documentObj: document, + windowObj: window, + options: [{ sourceType: 'domain', command: 'number.int', helpModel: { summary: '', params: [], example: '' } }], + currentCommand: 'number.int', + }); + + getOverlay().click(); + + await expect(promise).resolves.toBeNull(); + expect(getOverlay()).toBeNull(); + }); + test('restores focus to the trigger after closing with escape', async () => { const trigger = document.createElement('button'); trigger.textContent = 'Select faker command'; From 04570e0e428dfe731cb489d97b5b804a5d7645c9 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 25 Jun 2026 20:27:23 +0100 Subject: [PATCH 02/20] Address method picker review feedback --- .../method-help-display-view.js | 3 +- .../method-picker-dialog-utils.js | 35 ++++++++++++-- .../method-picker-dialog-view.js | 36 +++++++++++++- .../method-picker-dialog-components.test.js | 32 +++++++++++++ .../method-picker-dialog-controller.test.js | 47 +++++++++++++++++++ .../tests/utils/method-picker-modal.test.js | 24 ++++++++++ 6 files changed, 170 insertions(+), 7 deletions(-) diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js index c3a7ff8d..7d873a42 100644 --- a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-help-display-view.js @@ -6,6 +6,7 @@ import { renderParameterDetailsTable, renderParameterTypesTable, renderUsageExamples, + toSafeDocsUrl, } from './method-picker-dialog-utils.js'; class MethodHelpDisplayView { @@ -33,7 +34,7 @@ class MethodHelpDisplayView { const model = selected.helpModel || {}; const usageExamples = getUsageFunctionCalls(model); const returnExamples = getReturnExamples(model); - const docsUrl = String(model.docsUrl || '').trim(); + const docsUrl = toSafeDocsUrl(model.docsUrl); const hasParams = Array.isArray(model.params) && model.params.length > 0; this.root.innerHTML = `

    ${escapeHtml(selected.command)}

    diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js index f5fa74b2..519363e4 100644 --- a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js @@ -58,28 +58,54 @@ function getNextRecentEntries(command, recentEntries = []) { function toExampleList(value) { if (Array.isArray(value)) { - return value.map((entry) => String(entry || '').trim()).filter(Boolean); + return value.map((entry) => String(entry ?? '').trim()).filter(Boolean); } - const single = String(value || '').trim(); + const single = String(value ?? '').trim(); return single ? [single] : []; } +function safeStringify(value) { + try { + return JSON.stringify(value); + } catch { + try { + return String(value); + } catch { + return '[Unserializable value]'; + } + } +} + function normalizeReturnExampleValue(value) { if (typeof value === 'bigint') { return `${value}n`; } if (Array.isArray(value)) { - return JSON.stringify(value); + return safeStringify(value); } if (value instanceof Date) { return value.toISOString(); } if (value && typeof value === 'object') { - return JSON.stringify(value); + return safeStringify(value); } return String(value); } +function toSafeDocsUrl(value) { + const raw = String(value ?? '').trim(); + if (!raw) { + return ''; + } + if (/^https?:\/\//i.test(raw)) { + return raw; + } + if (/^\/(?!\/)/.test(raw)) { + return raw; + } + return ''; +} + function getUsageFunctionCalls(model) { return (Array.isArray(model?.usageExamples) ? model.usageExamples : []) .map((usageExample) => String(usageExample?.functionCall || '').trim()) @@ -312,6 +338,7 @@ export { renderUsageExamples, resolveSelectedCommandForFiltered, selectInitialCommand, + toSafeDocsUrl, splitFunctionCall, writeRecent, }; diff --git a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js index 12963c16..b0af4ca1 100644 --- a/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js +++ b/packages/core-ui/js/gui_components/shared/method-picker-dialog/method-picker-dialog-view.js @@ -71,7 +71,7 @@ class MethodPickerDialogView { this.applyButton = this.root.querySelector('[data-role="method-picker-apply-button"]'); this.root.addEventListener('click', this.handleOverlayClick); this.root.addEventListener('keydown', this.handleKeyDown); - this.render(); + this.updateApplyButtonState(); } getNavigatorProps() { @@ -98,10 +98,14 @@ class MethodPickerDialogView { } render() { - const state = this.controller.getState(); this.navigator?.update(this.getNavigatorProps()); this.list?.update(this.getListProps()); this.helpDisplay?.update(this.getHelpDisplayProps()); + this.updateApplyButtonState(); + } + + updateApplyButtonState() { + const state = this.controller.getState(); if (this.applyButton) { this.applyButton.disabled = state.applyDisabled; this.applyButton.setAttribute('aria-disabled', state.applyDisabled ? 'true' : 'false'); @@ -130,6 +134,10 @@ class MethodPickerDialogView { } onKeyDown(event) { + if (event.key === 'Tab') { + this.wrapFocusWithinDialog(event); + return; + } if (event.key === 'Escape') { event.preventDefault(); this.callbacks.onCancel?.({ reason: 'escape' }); @@ -147,6 +155,30 @@ class MethodPickerDialogView { } } + wrapFocusWithinDialog(event) { + const dialog = this.root?.querySelector?.('[data-role="method-picker-dialog"]'); + const focusable = Array.from( + dialog?.querySelectorAll?.( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) || [] + ).filter((element) => element.getAttribute('aria-hidden') !== 'true'); + if (focusable.length === 0) { + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeElement = this.documentObj?.activeElement; + if (event.shiftKey && activeElement === first) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + focusSearch() { this.navigator?.focusSearch(); } diff --git a/packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js b/packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js index 3b59a634..ea9ce239 100644 --- a/packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js +++ b/packages/core-ui/src/tests/utils/method-picker-dialog-components.test.js @@ -2,6 +2,7 @@ import { JSDOM } from 'jsdom'; import { createMethodHelpDisplay } from '../../../js/gui_components/shared/method-picker-dialog/method-help-display.js'; import { createMethodList } from '../../../js/gui_components/shared/method-picker-dialog/method-list.js'; import { createMethodNavigator } from '../../../js/gui_components/shared/method-picker-dialog/method-navigator.js'; +import { getReturnExamples } from '../../../js/gui_components/shared/method-picker-dialog/method-picker-dialog-utils.js'; describe('method picker dialog subcomponents', () => { let dom; @@ -121,4 +122,35 @@ describe('method picker dialog subcomponents', () => { component.update({ selectedOption: null }); expect(root.textContent).toContain('No method selected'); }); + + test('help display omits unsafe docs links and preserves falsy return examples', () => { + createMethodHelpDisplay({ + root, + props: { + selectedOption: { + sourceType: 'domain', + command: 'datatype.boolean', + helpModel: { + summary: 'Boolean helper', + docsUrl: 'javascript:alert(1)', + params: [], + returnExamples: [0, false, ''], + }, + }, + }, + }); + + expect(root.querySelector('.method-picker-docs-link a')).toBeNull(); + expect(root.textContent).toContain('0'); + expect(root.textContent).toContain('false'); + }); + + test('return examples fall back safely for circular values', () => { + const circular = { label: 'loop' }; + circular.self = circular; + + expect(getReturnExamples({ usageExamples: [{ functionCall: 'demo()', sampleReturnValue: circular }] })).toEqual([ + '[object Object]', + ]); + }); }); diff --git a/packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js b/packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js index ed639a32..a738af5f 100644 --- a/packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js +++ b/packages/core-ui/src/tests/utils/method-picker-dialog-controller.test.js @@ -104,4 +104,51 @@ describe('method picker dialog controller', () => { expect(controller.applySelection()).toEqual({ sourceType: 'domain', command: 'commerce.price' }); expect(written).toEqual([['commerce.price', 'helpers.arrayElement']]); }); + + test('ignores invalid and empty selections without clearing the current selection', () => { + const controller = new MethodPickerDialogController({ + props: { + options: OPTIONS, + currentCommand: 'commerce.price', + }, + }); + + controller.selectCommand('unknown.command'); + expect(controller.getState().selectedCommand).toBe('commerce.price'); + + controller.selectCommand(''); + expect(controller.getState().selectedCommand).toBe('commerce.price'); + }); + + test('leaves selection empty when selecting first filtered option from an empty result set', () => { + const controller = new MethodPickerDialogController({ + props: { + options: OPTIONS, + }, + }); + + controller.setSearchTerm('no matching command'); + expect(controller.getState().selectedCommand).toBe(''); + expect(controller.getState().applyDisabled).toBe(true); + + controller.selectFirstFilteredOption(); + expect(controller.getState().selectedCommand).toBe(''); + expect(controller.getState().applyDisabled).toBe(true); + }); + + test('re-normalizes selection when options change mid-session', () => { + const controller = new MethodPickerDialogController({ + props: { + options: OPTIONS, + currentCommand: 'commerce.price', + }, + }); + + controller.updateProps({ + options: [{ sourceType: 'domain', command: 'number.int', helpModel: { summary: 'Integer', params: [] } }], + }); + + expect(controller.getState().selectedCommand).toBe('number.int'); + expect(controller.applySelection()).toEqual({ sourceType: 'domain', command: 'number.int' }); + }); }); 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 d48f342f..4e536da9 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 @@ -136,6 +136,30 @@ describe('method picker modal', () => { expect(document.activeElement).toBe(trigger); }); + test('wraps tab focus within the modal dialog', async () => { + const promise = openMethodPickerModal({ + documentObj: document, + windowObj: window, + options: [{ sourceType: 'domain', command: 'number.int', helpModel: { summary: '', params: [], example: '' } }], + currentCommand: 'number.int', + }); + + const closeButton = getOverlay().querySelector('[data-role="method-picker-close-button"]'); + const applyButton = getOverlay().querySelector('[data-role="method-picker-apply-button"]'); + + closeButton.focus(); + closeButton.dispatchEvent( + new window.KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }) + ); + expect(document.activeElement).toBe(applyButton); + + applyButton.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(closeButton); + + getOverlay().querySelector('[data-role="method-picker-cancel-button"]').click(); + await promise; + }); + test('renders component-owned rooted hooks for overlay, tabs, list, detail, and tiles', async () => { const promise = openMethodPickerModal({ documentObj: document, From 346e1cb68d0b801a80d709ae316cc3f3239c22df Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Fri, 26 Jun 2026 06:41:48 +0100 Subject: [PATCH 03/20] Fix arrayElement params editor application --- .../schema/shared-schema-editor-controller.js | 7 ++- .../test-data/ui/params-editor-modal.css | 1 + .../test-data/ui/params-editor-modal.js | 47 ++++++++++++--- .../shared-schema-editor-controller.test.js | 58 +++++++++++++++++++ .../utils/faker-command-help-metadata.test.js | 8 +++ .../tests/utils/params-editor-modal.test.js | 57 ++++++++++++++++++ .../array-element-keyword-definition.js | 1 + 7 files changed, 169 insertions(+), 10 deletions(-) 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 3661fb0a..959f01f6 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 @@ -213,8 +213,11 @@ function createSharedSchemaEditorController({ RandExp, includeBracketGuidance: false, }) - .map((issue) => issue?.message) - .filter(Boolean); + .map((issue) => ({ + message: issue?.message, + severity: /Unsafe faker rule syntax detected/iu.test(String(issue?.message || '')) ? 'warning' : 'error', + })) + .filter((issue) => issue.message); }; const revalidateRows = () => { diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css index 4158d0c0..b663c276 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css @@ -130,6 +130,7 @@ body.theme-dark { background: var(--pe-error-soft, #fef3f2); } +.params-editor-warning[hidden], .params-editor-error[hidden] { display: none; } diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js index 9ffe4b74..e2ee7101 100644 --- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js +++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js @@ -202,6 +202,7 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { type: param?.type || '', optional: param?.optional === true, variadic: param?.variadic === true, + positionalOnly: param?.positionalOnly === true, description: param?.description || '', example: param?.example || '', examples: Array.isArray(param?.examples) ? param.examples : [], @@ -289,6 +290,7 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) { type: param?.type || '', optional: param?.optional === true, variadic: param?.variadic === true, + positionalOnly: param?.positionalOnly === true, description: param?.description || '', example: param?.example || '', examples: Array.isArray(param?.examples) ? param.examples : [], @@ -340,7 +342,6 @@ function buildParamsTextFromEditorEntries({ entries = [], validateParams = null -1 ); const errors = []; - if (lastFilledIndex < 0) { const requiredMissing = normalizedEntries.some((entry) => entry?.optional !== true); if (requiredMissing) { @@ -378,16 +379,36 @@ function buildParamsTextFromEditorEntries({ entries = [], validateParams = null const paramsText = formattedValues.length > 0 ? `(${formattedValues.join(',')})` : ''; const validationResult = typeof validateParams === 'function' ? validateParams(paramsText) : []; - const semanticErrors = Array.isArray(validationResult) - ? validationResult - : validationResult - ? [String(validationResult)] - : []; + const semanticIssues = ( + Array.isArray(validationResult) ? validationResult : validationResult ? [validationResult] : [] + ) + .map((issue) => { + if (!issue) { + return null; + } + if (typeof issue === 'string') { + return { message: issue, severity: 'error' }; + } + const message = String(issue?.message || issue || '').trim(); + if (!message) { + return null; + } + const severity = + String(issue?.severity || issue?.level || 'error').toLowerCase() === 'warning' ? 'warning' : 'error'; + return { message, severity }; + }) + .filter(Boolean); - return { + const semanticErrors = semanticIssues.filter((issue) => issue.severity !== 'warning').map((issue) => issue.message); + const semanticWarnings = semanticIssues.filter((issue) => issue.severity === 'warning').map((issue) => issue.message); + const result = { paramsText, - errors: [...errors, ...semanticErrors.filter(Boolean)], + errors: [...errors, ...semanticErrors], }; + if (semanticWarnings.length > 0) { + result.warnings = semanticWarnings; + } + return result; } function buildParamValidationRules(entry = {}) { @@ -723,6 +744,7 @@ function openParamsEditorModal({

    +