diff --git a/.storybook/main.js b/.storybook/main.js index f129c88e..8ff7bea5 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,10 +1,23 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { mergeConfig } from 'vite'; +import { resolveBuildVersion } from '../packages/core-ui/js/build-metadata/build-metadata.js'; +import { resolveSiteConfigModulePath } from '../packages/core-ui/js/site/site-config-module-path.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +function resolveStorybookSiteConfigModulePath(env = process.env) { + return resolveSiteConfigModulePath(env); +} + +function resolveStorybookBuildVersion(env = process.env, date = new Date()) { + return resolveBuildVersion({ + configuredVersion: env.ANYWAYDATA_BUILD_VERSION, + date, + }); +} + export default { framework: { name: '@storybook/html-vite', @@ -17,7 +30,12 @@ export default { autodocs: true, }, async viteFinal(config) { + const siteConfigModulePath = resolveStorybookSiteConfigModulePath(); + const buildVersion = resolveStorybookBuildVersion(); return mergeConfig(config, { + define: { + 'globalThis.__ANYWAYDATA_BUILD_VERSION__': JSON.stringify(buildVersion), + }, resolve: { alias: { 'https://cdn.skypack.dev/@faker-js/faker@v9.7.0': '@faker-js/faker', @@ -31,9 +49,11 @@ export default { '@anywaydata/core/command-help': path.resolve(__dirname, '../packages/core/js/command-help'), '@anywaydata/core/libs': path.resolve(__dirname, '../packages/core/js/libs'), '@anywaydata/core': path.resolve(__dirname, '../packages/core/src/index.js'), - '@anywaydata/site-config': path.resolve(__dirname, '../packages/core-ui/js/site/site-config.production.js'), + '@anywaydata/site-config': siteConfigModulePath, }, }, }); }, }; + +export { resolveStorybookBuildVersion, resolveStorybookSiteConfigModulePath }; diff --git a/apps/api/src/api-service.fromschema.test.js b/apps/api/src/api-service.fromschema.test.js index 1dcd0e32..43ecc8b0 100644 --- a/apps/api/src/api-service.fromschema.test.js +++ b/apps/api/src/api-service.fromschema.test.js @@ -74,7 +74,7 @@ describe('api-service handleFromSchemaRequest', () => { test('supports complex schema', () => { const { service } = createConcreteService(); - const body = `Name\nfirstName\nEmail\nemail\nAge\ndatatype.number({"min":18,"max":65})\nCity\nlocation.city`; + const body = `Name\nperson.firstName\nEmail\ninternet.email\nAge\nnumber.int(min=18,max=65)\nCity\nlocation.city`; const result = service.handleFromSchemaRequest({ body, query: { rowCount: '3' } }); expect(result.statusCode).toBe(200); diff --git a/apps/web/src/stories/method-picker-dialog.stories.js b/apps/web/src/stories/method-picker-dialog.stories.js index 0e46973e..4b4aee3b 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('option', { + 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.', }, }, }, @@ -494,13 +368,13 @@ export const VisualAlwaysOpen = { const canvas = within(canvasElement); await expect(canvas.getByRole('dialog', { name: 'Choose Method' })).toBeVisible(); await expect( - canvas.getByRole('button', { + canvas.getByRole('option', { name: 'helpers.arrayElement Returns one random element from the supplied array. faker', }) ).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.', }, }, }, @@ -532,7 +402,7 @@ export const ChooseFakerMethod = { await userEvent.click(canvas.getByRole('button', { name: 'Open method picker' })); const dialog = within(document.body); await userEvent.click( - dialog.getByRole('button', { + dialog.getByRole('option', { name: 'helpers.arrayElement Returns one random element from the supplied array. faker', }) ); @@ -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.', }, }, }, @@ -561,7 +431,7 @@ export const FilterAndChooseDomainMethod = { const dialog = within(document.body); await userEvent.type(dialog.getByRole('searchbox', { name: 'Filter methods' }), 'commerce'); await userEvent.click( - dialog.getByRole('button', { name: 'commerce.price Generates a price between min and max (inclusive). domain' }) + dialog.getByRole('option', { name: 'commerce.price Generates a price between min and max (inclusive). domain' }) ); await userEvent.click(dialog.getByRole('button', { name: 'Apply' })); await expect(canvas.getByText('domain:commerce.price')).toBeVisible(); @@ -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/apps/web/src/stories/shared-schema-definition.stories.js b/apps/web/src/stories/shared-schema-definition.stories.js index 2934bd7a..687c022f 100644 --- a/apps/web/src/stories/shared-schema-definition.stories.js +++ b/apps/web/src/stories/shared-schema-definition.stories.js @@ -472,7 +472,7 @@ export const CommandPicker = { await userEvent.clear(searchInput); await userEvent.type(searchInput, 'helpers.fake'); - const replacementTile = dialogScope.getByRole('button', { name: /helpers\.fake/i }); + const replacementTile = dialogScope.getByRole('option', { name: /helpers\.fake/i }); await userEvent.click(replacementTile); await userEvent.click(dialogScope.getByRole('button', { name: /^apply$/i })); diff --git a/apps/web/src/stories/story-cleanup.js b/apps/web/src/stories/story-cleanup.js index ec52699b..691bcc1e 100644 --- a/apps/web/src/stories/story-cleanup.js +++ b/apps/web/src/stories/story-cleanup.js @@ -1,3 +1,5 @@ +import { restoreMethodPickerDocumentScrollLock } from '../../../../packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js'; + const STORY_ARTIFACT_SELECTORS = [ '#confirm-modal-backdrop', '#text-input-modal-backdrop', @@ -41,6 +43,7 @@ function removeStoryArtifacts(documentObj = getDefaultDocumentObj()) { return; } + restoreMethodPickerDocumentScrollLock(documentObj); STORY_ARTIFACT_SELECTORS.forEach((selector) => { documentObj.querySelectorAll(selector).forEach((element) => element.remove()); }); diff --git a/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js b/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js index 8b23fdda..dceed42e 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js @@ -40,9 +40,9 @@ class ConfirmDialogComponent { await this.expectHidden(); } - async cancel() { + async cancel({ cancelLabel = /^cancel$/i } = {}) { await this.expectVisible(); - await this.backdrop.getByRole('button', { name: /^cancel$/i }).click(); + await this.backdrop.getByRole('button', { name: cancelLabel }).click(); await this.expectHidden(); } } diff --git a/apps/web/src/tests/browser/app/abstractions/components/format-options-panel.component.js b/apps/web/src/tests/browser/app/abstractions/components/format-options-panel.component.js index 927af14a..22c6e59f 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/format-options-panel.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/format-options-panel.component.js @@ -101,6 +101,16 @@ class FormatOptionsPanelComponent { throw new Error(`Could not find option control named "${controlName}"`); } + async expectCsvOptionAccessibleNames() { + await expect(this.container.getByRole('checkbox', { name: 'Use Quotes', exact: true })).toBeVisible(); + await expect(this.container.getByRole('textbox', { name: 'Quote Char', exact: true })).toBeVisible(); + await expect(this.container.getByRole('checkbox', { name: /show help for this option use quotes/i })).toHaveCount( + 0 + ); + await expect(this.container.getByRole('textbox', { name: /show help for this option quote char/i })).toHaveCount(0); + await expect(this.container.getByRole('button', { name: 'Show help for this option' }).first()).toBeVisible(); + } + async setFirstEditableOption() { const select = this.container.locator('select').first(); if (await select.count()) { diff --git a/apps/web/src/tests/browser/app/functional/export-options/option-accessible-names.spec.js b/apps/web/src/tests/browser/app/functional/export-options/option-accessible-names.spec.js new file mode 100644 index 00000000..232546a0 --- /dev/null +++ b/apps/web/src/tests/browser/app/functional/export-options/option-accessible-names.spec.js @@ -0,0 +1,13 @@ +const { test } = require('@playwright/test'); +const { openApp, expectNoPageErrors } = require('../../abstractions/helpers/scenario-helpers'); + +test.describe('6. Export Options and Controls', () => { + test('CSV option accessible names exclude neighboring help button text', async ({ page }) => { + const { appPage, pageErrors } = await openApp(page); + + await appPage.textPreviewEditor.selectFormat('CSV'); + await appPage.formatOptionsPanel.expectCsvOptionAccessibleNames(); + + expectNoPageErrors(pageErrors); + }); +}); diff --git a/apps/web/src/tests/browser/app/functional/test-data/text-schema-grid-sync.spec.js b/apps/web/src/tests/browser/app/functional/test-data/text-schema-grid-sync.spec.js index 94a374a3..04c9cc0b 100644 --- a/apps/web/src/tests/browser/app/functional/test-data/text-schema-grid-sync.spec.js +++ b/apps/web/src/tests/browser/app/functional/test-data/text-schema-grid-sync.spec.js @@ -56,7 +56,7 @@ test.describe('7. Test Data Generation', () => { expectNoPageErrors(pageErrors); }); - test('invalid domain command text preserves the domain row type in the app editor', async ({ page }) => { + test('invalid command text prompts before converting to literal rows in the app editor', async ({ page }) => { const { appPage, pageErrors } = await openApp(page); await appPage.testDataPanel.expand(); @@ -68,16 +68,47 @@ test.describe('7. Test Data Generation', () => { await expect.poll(async () => appPage.testDataPanel.getSchemaSourceType(0)).toBe('domain'); await appPage.testDataPanel.setSchemaText('Name\nperson.fullNam'); + await appPage.testDataPanel.schemaEditor.modeToggleButton.click(); + await appPage.testDataPanel.confirmDialog.expectVisible(); + await expect(appPage.testDataPanel.confirmDialog.backdrop).toContainText( + 'Syntax errors are present that can not be edited in Schema UI. Allow editing by converting invalid definitions to literal?' + ); + await appPage.testDataPanel.confirmDialog.cancel({ cancelLabel: /^no$/i }); - await expect.poll(async () => appPage.testDataPanel.getSchemaCell(0, 'type')).toBe('person.fullNam'); - await expect.poll(async () => appPage.testDataPanel.getSchemaSourceType(0)).toBe('domain'); - - await expect(appPage.testDataPanel.getSchemaRow(0)).toHaveClass(/shared-schema-row-invalid/); + await expect.poll(async () => appPage.testDataPanel.isRowEditorMode()).toBe(false); + await expect + .poll(async () => appPage.testDataPanel.getSchemaErrorText()) + .toContain('Unknown keyword: person.fullNam'); await appPage.testDataPanel.clickGenerate(); await expect .poll(async () => appPage.testDataPanel.getSchemaErrorText()) - .toContain('Row 1: unknown domain command "person.fullNam".'); + .toContain('Unknown keyword: person.fullNam'); + + await appPage.testDataPanel.schemaEditor.modeToggleButton.click(); + await appPage.testDataPanel.confirmDialog.confirm({ confirmLabel: /^yes$/i }); + + await expect.poll(async () => appPage.testDataPanel.isRowEditorMode()).toBe(true); + await expect.poll(async () => appPage.testDataPanel.getSchemaSourceType(0)).toBe('literal'); + await expect.poll(async () => appPage.testDataPanel.getSchemaCell(0, 'value')).toBe('person.fullNam'); + + expectNoPageErrors(pageErrors); + }); + + test('regex shorthand and literal shorthand sync into the app schema grid', async ({ page }) => { + const { appPage, pageErrors } = await openApp(page); + + await appPage.testDataPanel.expand(); + await appPage.testDataPanel.expectExpanded(); + + await appPage.testDataPanel.setSchemaText('Order Code\n[A-Z]{3}\nCity\nLondon'); + await appPage.testDataPanel.schemaEditor.modeToggleButton.click(); + + await expect.poll(async () => appPage.testDataPanel.getSchemaRowCount()).toBe(2); + await expect.poll(async () => appPage.testDataPanel.getSchemaSourceType(0)).toBe('regex'); + await expect.poll(async () => appPage.testDataPanel.getSchemaCell(0, 'value')).toBe('[A-Z]{3}'); + await expect.poll(async () => appPage.testDataPanel.getSchemaSourceType(1)).toBe('literal'); + await expect.poll(async () => appPage.testDataPanel.getSchemaCell(1, 'value')).toBe('London'); expectNoPageErrors(pageErrors); }); diff --git a/apps/web/src/tests/browser/app/functional/ui-navigation/responsive-layout.spec.js b/apps/web/src/tests/browser/app/functional/ui-navigation/responsive-layout.spec.js index 6c7e8653..245c4c9f 100644 --- a/apps/web/src/tests/browser/app/functional/ui-navigation/responsive-layout.spec.js +++ b/apps/web/src/tests/browser/app/functional/ui-navigation/responsive-layout.spec.js @@ -1,5 +1,5 @@ const { test } = require('@playwright/test'); -const { openApp, expectNoPageErrors } = require('../../abstractions/helpers/scenario-helpers'); +const { openApp, expectNoPageErrors, expect } = require('../../abstractions/helpers/scenario-helpers'); test.describe('10. User Interface and Navigation', () => { test('Responsive Layout', async ({ page }) => { @@ -13,4 +13,21 @@ test.describe('10. User Interface and Navigation', () => { expectNoPageErrors(pageErrors); }); + + for (const viewportWidth of [390, 320]) { + test(`App page does not overflow horizontally at ${viewportWidth}px`, async ({ page }) => { + await page.setViewportSize({ width: viewportWidth, height: 844 }); + const { appPage, pageErrors } = await openApp(page); + + await appPage.gridEditor.expectVisible(); + + await expect + .poll(async () => + page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + ) + .toBeLessThanOrEqual(0); + + expectNoPageErrors(pageErrors); + }); + } }); diff --git a/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js b/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js index fe3d1a5a..b8637125 100644 --- a/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js +++ b/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js @@ -84,7 +84,151 @@ test.describe('Generator Schema Editing', () => { expectNoPageErrors(pageErrors); }); - test('invalid domain command text preserves the domain row type in the generator editor', async ({ page }) => { + test('method picker keeps PageDown from scrolling the generator page background', async ({ page }) => { + const { generatorPage, pageErrors } = await openGenerator(page); + + await page.keyboard.press('Home'); + await expect.poll(async () => page.evaluate(() => window.scrollY)).toBe(0); + + await generatorPage.schema.setTextMode(false); + await generatorPage.schema.setRowSourceType(0, 'domain'); + await generatorPage.schema.editor.dismissOpenHelpTooltips(); + await generatorPage.schema.row(0).locator('[data-action="pick-command"]').click(); + await generatorPage.schema.editor.methodPicker.expectOpen(); + + const scrollBeforePageDown = await page.evaluate(() => window.scrollY); + await page.keyboard.press('PageDown'); + + await expect.poll(async () => page.evaluate(() => window.scrollY)).toBe(scrollBeforePageDown); + + await page.keyboard.press('Escape'); + await expect(generatorPage.schema.editor.methodPicker.overlay).toHaveCount(0); + expectNoPageErrors(pageErrors); + }); + + test('method picker uses arrow keys rather than tabbing through the method list', async ({ page }) => { + const { generatorPage, pageErrors } = await openGenerator(page); + + await generatorPage.schema.setTextMode(false); + await generatorPage.schema.setRowSourceType(0, 'domain'); + await generatorPage.schema.editor.dismissOpenHelpTooltips(); + await generatorPage.schema.row(0).locator('[data-action="pick-command"]').click(); + await generatorPage.schema.editor.methodPicker.expectOpen(); + + const tiles = generatorPage.schema.editor.methodPicker.overlay.locator('[data-role="method-picker-tile"]'); + await expect(tiles.nth(1)).toBeVisible(); + + const firstTile = tiles.first(); + const secondTile = tiles.nth(1); + const secondCommand = await secondTile.locator('[data-role="method-picker-command"]').innerText(); + + await firstTile.click(); + await expect(firstTile).toBeFocused(); + await expect(firstTile).toHaveAttribute('tabindex', '0'); + await expect(secondTile).toHaveAttribute('tabindex', '-1'); + + await page.keyboard.press('ArrowDown'); + await expect(secondTile).toBeFocused(); + await expect(secondTile).toHaveAttribute('tabindex', '0'); + await expect(firstTile).toHaveAttribute('tabindex', '-1'); + await expect( + generatorPage.schema.editor.methodPicker.overlay.locator('[data-role="method-picker-tile"].is-selected') + ).toContainText(secondCommand); + + await page.keyboard.press('Tab'); + await expect( + generatorPage.schema.editor.methodPicker.overlay.locator('[data-role="method-picker-tile"]:focus') + ).toHaveCount(0); + + await page.keyboard.press('Shift+Tab'); + await expect(secondTile).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(generatorPage.schema.editor.methodPicker.overlay).toHaveCount(0); + expectNoPageErrors(pageErrors); + }); + + test('method picker restores focus to the command button after applying a selection', async ({ page }) => { + const { generatorPage, pageErrors } = await openGenerator(page); + + await generatorPage.schema.setTextMode(false); + await generatorPage.schema.setRowSourceType(0, 'domain'); + await generatorPage.schema.editor.dismissOpenHelpTooltips(); + await generatorPage.schema.row(0).locator('[data-action="pick-command"]').click(); + await generatorPage.schema.editor.methodPicker.chooseCommand('internet.httpMethod'); + + const commandButton = generatorPage.schema.row(0).locator('[data-action="pick-command"]'); + await expect(commandButton).toHaveText('internet.httpMethod'); + await expect(commandButton).toBeFocused(); + + expectNoPageErrors(pageErrors); + }); + + test('method picker applies a focused selected command tile with Enter', async ({ page }) => { + const { generatorPage, pageErrors } = await openGenerator(page); + + await generatorPage.schema.setTextMode(false); + await generatorPage.schema.setRowSourceType(0, 'domain'); + await generatorPage.schema.editor.dismissOpenHelpTooltips(); + await generatorPage.schema.row(0).locator('[data-action="pick-command"]').click(); + await generatorPage.schema.editor.methodPicker.expectOpen(); + + const picker = generatorPage.schema.editor.methodPicker; + await picker.searchInput.fill('internet.httpMethod'); + const targetTile = picker.overlay.locator('[data-role="method-picker-tile"]', { + hasText: 'internet.httpMethod', + }); + await expect(targetTile).toBeVisible(); + await targetTile.click(); + await expect(targetTile).toBeFocused(); + + await page.keyboard.press('Enter'); + + await expect(picker.overlay).toHaveCount(0); + await expect(generatorPage.schema.row(0).locator('[data-action="pick-command"]')).toHaveText('internet.httpMethod'); + + expectNoPageErrors(pageErrors); + }); + + test('method picker active filter tabs meet light theme text contrast', async ({ page }) => { + const { generatorPage, pageErrors } = await openGenerator(page); + + await expect(page.locator('body')).toHaveClass(/theme-light/); + await generatorPage.schema.setTextMode(false); + await generatorPage.schema.setRowSourceType(0, 'domain'); + await generatorPage.schema.editor.dismissOpenHelpTooltips(); + await generatorPage.schema.row(0).locator('[data-action="pick-command"]').click(); + await generatorPage.schema.editor.methodPicker.expectOpen(); + + const activeTab = generatorPage.schema.editor.methodPicker.overlay + .locator('[data-role="method-picker-tab"].is-active') + .first(); + await expect(activeTab).toBeVisible(); + const contrast = await activeTab.evaluate((element) => { + function parseRgb(value) { + return (value.match(/\d+/g) || []).slice(0, 3).map((part) => Number(part)); + } + function channel(value) { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + } + function luminance(rgb) { + const [red, green, blue] = rgb.map(channel); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; + } + const styles = window.getComputedStyle(element); + const foreground = luminance(parseRgb(styles.color)); + const background = luminance(parseRgb(styles.backgroundColor)); + return (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05); + }); + expect(contrast).toBeGreaterThanOrEqual(4.5); + + await page.keyboard.press('Escape'); + await expect(generatorPage.schema.editor.methodPicker.overlay).toHaveCount(0); + expectNoPageErrors(pageErrors); + }); + + test('invalid command text prompts before converting to literal rows in the generator editor', async ({ page }) => { const { generatorPage, pageErrors } = await openGenerator(page); await generatorPage.schema.setSchemaText('Name\nperson.fullName'); @@ -95,17 +239,42 @@ test.describe('Generator Schema Editing', () => { await expect(generatorPage.schema.row(0).locator('[data-action="pick-command"]')).toHaveText('person.fullName'); await generatorPage.schema.setSchemaText('Name\nperson.fullNam'); - await generatorPage.schema.setTextMode(false); - - await expect(generatorPage.schema.row(0).locator('select[data-field="sourceType"]')).toHaveValue('domain'); - await expect(generatorPage.schema.row(0).locator('[data-action="pick-command"]')).toHaveText('person.fullNam'); - await expect(generatorPage.schema.row(0)).toHaveClass(/shared-schema-row-invalid/); - await expect(generatorPage.schema.row(0).locator('.shared-schema-row-validation')).toContainText( - 'Row 1: unknown domain command "person.fullNam".' + await generatorPage.schema.modeToggleButton.click(); + await generatorPage.schema.confirmDialog.expectVisible(); + await expect(generatorPage.schema.confirmDialog.backdrop).toContainText( + 'Syntax errors are present that can not be edited in Schema UI. Allow editing by converting invalid definitions to literal?' ); + await generatorPage.schema.confirmDialog.cancel({ cancelLabel: /^no$/i }); + + await expect.poll(async () => generatorPage.schema.editor.isRowEditorMode()).toBe(false); + await expect(generatorPage.schema.modeToggleButton).toHaveText('Edit as Schema'); + await expect(generatorPage.schema.errorStatus).toContainText('Unknown keyword: person.fullNam'); await generatorPage.preview.clickPreview(); - await expect(generatorPage.schema.errorStatus).toContainText('Row 1: unknown domain command "person.fullNam".'); + await expect(generatorPage.schema.errorStatus).toContainText('Unknown keyword: person.fullNam'); + await expect.poll(async () => generatorPage.preview.getOutputPreviewText()).toBe(''); + + await generatorPage.schema.modeToggleButton.click(); + await generatorPage.schema.confirmDialog.confirm({ confirmLabel: /^yes$/i }); + + await expect(generatorPage.schema.rows).toHaveCount(1); + await expect(generatorPage.schema.row(0).locator('select[data-field="sourceType"]')).toHaveValue('literal'); + await expect(generatorPage.schema.row(0).locator('input[data-field="value"]')).toHaveValue('person.fullNam'); + + expectNoPageErrors(pageErrors); + }); + + test('regex and literal shorthand keep their strict parsing behavior in the generator editor', async ({ page }) => { + const { generatorPage, pageErrors } = await openGenerator(page); + + await generatorPage.schema.setSchemaText('Code\n[A-Z]{3}\nCity\nLondon'); + await generatorPage.schema.setTextMode(false); + + await expect(generatorPage.schema.rows).toHaveCount(2); + await expect(generatorPage.schema.row(0).locator('select[data-field="sourceType"]')).toHaveValue('regex'); + await expect(generatorPage.schema.row(0).locator('input[data-field="value"]')).toHaveValue('[A-Z]{3}'); + await expect(generatorPage.schema.row(1).locator('select[data-field="sourceType"]')).toHaveValue('literal'); + await expect(generatorPage.schema.row(1).locator('input[data-field="value"]')).toHaveValue('London'); expectNoPageErrors(pageErrors); }); @@ -163,7 +332,8 @@ test.describe('Generator Schema Editing', () => { await expect( generatorPage.page .locator('.tippy-content') - .filter({ hasText: 'Faker commands generate realistic random values' }) + .filter({ hasText: 'Faker helper commands allow use of more complex generation than the domain commands' }) + .filter({ hasText: 'helpers.rangeToNumber({ min: 1, max: 10 })' }) ).toBeVisible(); await generatorPage.schema.setRowSourceType(0, 'literal'); diff --git a/apps/web/src/tests/jest/build-version-config.test.js b/apps/web/src/tests/jest/build-version-config.test.js new file mode 100644 index 00000000..40adc7d3 --- /dev/null +++ b/apps/web/src/tests/jest/build-version-config.test.js @@ -0,0 +1,47 @@ +import storybookConfig, { resolveStorybookBuildVersion } from '../../../../../.storybook/main.js'; +import webViteConfig, { resolveWebBuildVersion } from '../../../vite.config.mjs'; + +describe('build version config', () => { + test('web vite config uses override values or UTC build-time values', () => { + expect( + resolveWebBuildVersion({ ANYWAYDATA_BUILD_VERSION: 'v20261224.2359' }, new Date('2026-05-19T01:02:00Z')) + ).toBe('v20261224.2359'); + expect(resolveWebBuildVersion({}, new Date('2026-05-19T01:02:00Z'))).toBe('v20260519.0102'); + }); + + test('web vite config injects the build version into global metadata', async () => { + const originalBuildVersion = process.env.ANYWAYDATA_BUILD_VERSION; + + try { + process.env.ANYWAYDATA_BUILD_VERSION = 'v20261224.2359'; + const config = await webViteConfig(); + + expect(config.define['globalThis.__ANYWAYDATA_BUILD_VERSION__']).toBe('"v20261224.2359"'); + } finally { + if (originalBuildVersion === undefined) { + delete process.env.ANYWAYDATA_BUILD_VERSION; + } else { + process.env.ANYWAYDATA_BUILD_VERSION = originalBuildVersion; + } + } + }); + + test('storybook config uses the same build version injection shape', async () => { + const originalBuildVersion = process.env.ANYWAYDATA_BUILD_VERSION; + + try { + process.env.ANYWAYDATA_BUILD_VERSION = 'v20261224.2359'; + + expect(resolveStorybookBuildVersion(process.env, new Date('2026-05-19T01:02:00Z'))).toBe('v20261224.2359'); + const viteConfig = await storybookConfig.viteFinal({}); + + expect(viteConfig.define['globalThis.__ANYWAYDATA_BUILD_VERSION__']).toBe('"v20261224.2359"'); + } finally { + if (originalBuildVersion === undefined) { + delete process.env.ANYWAYDATA_BUILD_VERSION; + } else { + process.env.ANYWAYDATA_BUILD_VERSION = originalBuildVersion; + } + } + }); +}); diff --git a/apps/web/src/tests/jest/storybook/story-cleanup.test.js b/apps/web/src/tests/jest/storybook/story-cleanup.test.js index a0ecd3e5..9cdfa980 100644 --- a/apps/web/src/tests/jest/storybook/story-cleanup.test.js +++ b/apps/web/src/tests/jest/storybook/story-cleanup.test.js @@ -7,6 +7,7 @@ import { renderStoryWithCleanup, removeStoryArtifacts, } from '../../../stories/story-cleanup.js'; +import { openMethodPickerModal } from '../../../../../../packages/core-ui/js/gui_components/shared/test-data/ui/method-picker-modal.js'; describe('story cleanup helpers', () => { let dom; @@ -48,6 +49,28 @@ describe('story cleanup helpers', () => { expect(document.body.children).toHaveLength(0); }); + test('removeStoryArtifacts restores method picker scroll lock when clearing a stale overlay', () => { + document.body.style.overflow = 'auto'; + document.documentElement.style.overflow = 'scroll'; + + openMethodPickerModal({ + documentObj: document, + windowObj: dom.window, + options: [{ sourceType: 'domain', command: 'number.int', helpModel: { summary: '', params: [], example: '' } }], + currentCommand: 'number.int', + }); + + expect(document.querySelector('[data-role="method-picker-overlay"]')).not.toBeNull(); + expect(document.body.style.overflow).toBe('hidden'); + expect(document.documentElement.style.overflow).toBe('hidden'); + + removeStoryArtifacts(document); + + expect(document.querySelector('[data-role="method-picker-overlay"]')).toBeNull(); + expect(document.body.style.overflow).toBe('auto'); + expect(document.documentElement.style.overflow).toBe('scroll'); + }); + test('registerStoryCleanup supports async story results', async () => { const root = document.createElement('section'); root.__storybookCleanup = jest.fn(); diff --git a/apps/web/src/tests/jest/testenv-site-config-override.test.js b/apps/web/src/tests/jest/testenv-site-config-override.test.js index 8717cbff..6f3cb80e 100644 --- a/apps/web/src/tests/jest/testenv-site-config-override.test.js +++ b/apps/web/src/tests/jest/testenv-site-config-override.test.js @@ -1,6 +1,10 @@ import { readFile, rm } from 'node:fs/promises'; import path from 'node:path'; +import storybookConfig, { resolveStorybookSiteConfigModulePath } from '../../../../../.storybook/main.js'; +import { resolveSiteConfigModulePath as resolveWebSiteConfigModulePath } from '../../../vite.config.mjs'; +import { DEFAULT_SITE_CONFIG_MODULE_PATH } from '../../../../../packages/core-ui/js/site/site-config-module-path.js'; import { + createSiteConfigOverrideBuildEnv, createTestEnvSiteConfigInput, writeGeneratedTestEnvSiteConfigOverride, } from '../../../../../scripts/create-testenv.mjs'; @@ -34,4 +38,41 @@ describe('testenv site-config override generation', () => { await rm(overridePath, { force: true }); } }); + + test('exposes the site-config override env used by testenv app and storybook builds', () => { + const overridePath = path.join(process.cwd(), 'testenv', '_site-config.override.mjs'); + + expect(createSiteConfigOverrideBuildEnv(overridePath, { buildVersion: 'v20260519.0102' })).toEqual({ + ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH: overridePath, + ANYWAYDATA_BUILD_VERSION: 'v20260519.0102', + }); + expect(createSiteConfigOverrideBuildEnv(overridePath, { buildVersion: 'bad' })).toEqual({ + ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH: overridePath, + }); + }); + + test('storybook resolves the same site-config override alias as the main app build', async () => { + const originalOverridePath = process.env.ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH; + const overridePath = path.join(process.cwd(), 'testenv', '_site-config.override.mjs'); + + try { + delete process.env.ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH; + expect(resolveStorybookSiteConfigModulePath()).toBe(DEFAULT_SITE_CONFIG_MODULE_PATH); + expect(resolveWebSiteConfigModulePath()).toBe(DEFAULT_SITE_CONFIG_MODULE_PATH); + + process.env.ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH = overridePath; + expect(resolveStorybookSiteConfigModulePath()).toBe(overridePath); + expect(resolveWebSiteConfigModulePath()).toBe(overridePath); + + const viteConfig = await storybookConfig.viteFinal({}); + expect(viteConfig.resolve.alias['@anywaydata/site-config']).toBe(overridePath); + expect(viteConfig.define['globalThis.__ANYWAYDATA_BUILD_VERSION__']).toMatch(/^"v\d{8}\.\d{4}"$/); + } finally { + if (originalOverridePath === undefined) { + delete process.env.ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH; + } else { + process.env.ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH = originalOverridePath; + } + } + }); }); diff --git a/apps/web/styles.css b/apps/web/styles.css index 9e1c13a6..ea6d4502 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -121,6 +121,7 @@ body.theme-dark a:hover { color: var(--header-text); display: flex; height: 2.4em; + box-sizing: border-box; } div.header div { @@ -583,6 +584,46 @@ body.theme-dark .writer-schema-progress-output li { } @media (max-width: 640px) { + .import-export-toolbar__segment--import, + .import-export-toolbar__segment--download, + .import-export-import-control, + .import-export-download-control, + .export-encoding-settings, + .export-encoding-settings__panel, + .import-export-import-control__settings, + .import-export-import-control__settings-panel { + box-sizing: border-box; + min-width: 0; + max-width: 100%; + } + + .import-export-toolbar__segment--import, + .import-export-toolbar__segment--download, + .import-export-import-control, + .import-export-download-control, + .export-encoding-settings, + .import-export-import-control__settings, + .import-export-import-control__settings-panel { + width: 100%; + } + + .export-encoding-settings { + display: block; + } + + .export-encoding-settings__panel { + width: 100%; + } + + .export-encoding-settings__field { + min-width: 0; + } + + .export-encoding-settings__field select { + width: 100%; + min-width: 0; + } + .generator-export-encoding-settings { margin-left: 0; } @@ -1145,6 +1186,35 @@ body.theme-dark .webmcp-code-block { background: color-mix(in srgb, var(--header-bg) 70%, #ffffff 30%); } +@media (max-width: 640px) { + .header { + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + height: auto; + min-height: 2.4em; + padding: 0.35rem 0.5rem; + } + + div.header div { + margin-top: 0; + } + + .header .pageheading, + .header .mainmenu { + padding-left: 0; + } + + .header .pageheading { + flex: 1 1 auto; + min-width: 0; + } + + .theme-toggle-container { + margin: 0; + } +} + .mainmenu a:hover, .header .pageheading a:hover { color: var(--page-text); @@ -2136,6 +2206,37 @@ body.theme-dark .shared-schema-row-validation { } } +@media (max-width: 480px) { + .shared-schema-row { + grid-template-columns: minmax(0, 1fr); + } + + .shared-schema-row-actions, + .shared-schema-row > input[data-field='name'], + .shared-schema-row > select[data-field='sourceType'], + .shared-schema-row > .shared-schema-command-picker-control, + .shared-schema-row > .shared-schema-help-link, + .shared-schema-row > .shared-schema-params-control, + .shared-schema-row > input[data-field='value'] { + grid-column: 1; + grid-row: auto; + min-width: 0; + } + + .shared-schema-row > input[data-field='name'], + .shared-schema-row > select[data-field='sourceType'], + .shared-schema-row > .shared-schema-command-picker-control, + .shared-schema-row > .shared-schema-params-control, + .shared-schema-row > input[data-field='value'] { + width: 100%; + } + + .shared-schema-row > .shared-schema-help-link { + justify-self: start; + margin: 0; + } +} + .theme-doc-markdown table { display: block; width: 100%; diff --git a/apps/web/vite.config.mjs b/apps/web/vite.config.mjs index d0be43cc..38671175 100644 --- a/apps/web/vite.config.mjs +++ b/apps/web/vite.config.mjs @@ -2,28 +2,41 @@ import { defineConfig } from 'vite'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { transformStandaloneHtmlWithSiteConfig } from './site-config-html.mjs'; +import { resolveBuildVersion } from '../../packages/core-ui/js/build-metadata/build-metadata.js'; +import { resolveSiteConfigModulePath as resolveSharedSiteConfigModulePath } from '../../packages/core-ui/js/site/site-config-module-path.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const defaultSiteConfigModulePath = path.resolve(__dirname, '../../packages/core-ui/js/site/site-config.production.js'); -function resolveSiteConfigModulePath() { - return process.env.ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH - ? path.resolve(process.env.ANYWAYDATA_SITE_CONFIG_OVERRIDE_PATH) - : defaultSiteConfigModulePath; +function resolveSiteConfigModulePath(env = process.env) { + return resolveSharedSiteConfigModulePath(env); +} + +function resolveWebBuildVersion(env = process.env, date = new Date()) { + return resolveBuildVersion({ + configuredVersion: env.ANYWAYDATA_BUILD_VERSION, + date, + }); } export default defineConfig(async () => { const siteConfigModulePath = resolveSiteConfigModulePath(); const siteConfigModule = await import(pathToFileURL(siteConfigModulePath).href); const siteConfig = siteConfigModule.siteConfig || siteConfigModule.default; + const buildVersion = resolveWebBuildVersion(); return { root: __dirname, + define: { + 'globalThis.__ANYWAYDATA_BUILD_VERSION__': JSON.stringify(buildVersion), + }, resolve: { alias: [ { find: /^@anywaydata\/core$/, replacement: path.resolve(__dirname, '../../packages/core/src/index.js') }, - { find: /^@anywaydata\/core\/mcp\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/mcp/$1') }, + { + find: /^@anywaydata\/core\/mcp\/(.*)$/, + replacement: path.resolve(__dirname, '../../packages/core/js/mcp/$1'), + }, { find: /^@anywaydata\/core\/faker\/(.*)$/, replacement: path.resolve(__dirname, '../../packages/core/js/faker/$1'), @@ -94,3 +107,5 @@ export default defineConfig(async () => { }, }; }); + +export { resolveSiteConfigModulePath, resolveWebBuildVersion }; diff --git a/docs-src/docs/040-test-data/018-Schema-Definition.md b/docs-src/docs/040-test-data/018-Schema-Definition.md index e23b0b7f..3677c69d 100644 --- a/docs-src/docs/040-test-data/018-Schema-Definition.md +++ b/docs-src/docs/040-test-data/018-Schema-Definition.md @@ -77,11 +77,15 @@ Use a regex when the value should match a pattern. ```text Ticket Id -[A-Z]{3}-\d{4} +regex([A-Z]{3}-\d{4}) ``` This generates values such as `ABC-1234`. +Raw regex patterns are still accepted for quick entry when the text does not look like a command. For example, `[A-Z]{3}` is treated as a regex shorthand and is shown as `regex([A-Z]{3})` when schema text is refreshed after previewing or generating data. + +Command-like text is validated as a command before regex or literal shorthand is considered. A dotted identifier such as `person.fullName`, `person.fullName()`, `person.notACommand`, or `person.notACommand()` must resolve to a supported AnyWayData domain command or allowed Faker helper. Unknown command-like text is reported as a schema error instead of being interpreted as regex or literal text. To use command-looking text as data, wrap it explicitly with `literal(...)`; to use it as a regex, wrap it explicitly with `regex(...)`. + ### Domain and Faker-style methods Use a domain method for realistic generated values. @@ -98,6 +102,12 @@ autoIncrement.timestamp(start="2026-06-12T12:39:23Z", step=1, type="seconds") The `Customer Name` example generates names such as `Alice Smith`. The `CreatedAt` example generates deterministic timestamps for time-ordered rows. +## Switching text schemas back to the Schema UI + +When you switch from **Edit as Text** to **Edit as Schema**, AnyWayData validates the text first. If the text contains invalid rule definitions, the app asks whether to convert those invalid definitions to literal values so they can be edited in the Schema UI. + +Choosing **Yes** keeps valid rows as their detected types and converts only invalid rule definitions to literal rows using the original rule text. Choosing **No**, pressing Escape, or dismissing the dialog keeps you in **Edit as Text** mode with the schema error visible. Constraint errors are not converted to literals; fix those in text mode first. + ## Comments and blank lines You can use blank lines to make a schema easier to read. @@ -366,7 +376,7 @@ This is invalid because `bob` does not match the regex: ```text Ticket Id -[A-Z]{3}-\d{4} +regex([A-Z]{3}-\d{4}) IF [Ticket Id] = "bob" THEN [Status] = "Open"; ``` @@ -502,7 +512,7 @@ Illustrates: ```text Ticket Id -[A-Z]{3}-\d{4} +regex([A-Z]{3}-\d{4}) Queue enum("Support","QA","Ops") diff --git a/docs-src/docs/index.md b/docs-src/docs/index.md new file mode 100644 index 00000000..c91ce989 --- /dev/null +++ b/docs-src/docs/index.md @@ -0,0 +1,21 @@ +--- +slug: / +sidebar_position: 0 +title: Documentation +--- + +# AnyWayData Documentation + +AnyWayData is a browser-based data table editor and test data generator. + +Use these docs to find the main workflows: + +- [About AnyWayData](./intro) +- [Editing data](./editing-data/editable-grid) +- [Importing from files](./editing-data/import-from-file) +- [Exporting data](./editing-data/exporting-data) +- [Generating test data](./test-data/test-data-generation) +- [Schema definition](./test-data/Schema-Definition) +- [Domain test data commands](./test-data/domain/domain-test-data) +- [REST API](./interfaces-and-deployment/rest-api) +- [CLI](./interfaces-and-deployment/cli-node-and-bun) 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/build-metadata/build-metadata.js b/packages/core-ui/js/build-metadata/build-metadata.js new file mode 100644 index 00000000..543357bc --- /dev/null +++ b/packages/core-ui/js/build-metadata/build-metadata.js @@ -0,0 +1,52 @@ +const BUILD_VERSION_PATTERN = /^v\d{8}\.\d{4}$/; +const FALLBACK_BUILD_VERSION = 'v00000000.0000'; + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function formatUtcBuildVersion(date = new Date()) { + const dateObj = date instanceof Date ? date : new Date(date); + if (!Number.isFinite(dateObj.getTime())) { + return FALLBACK_BUILD_VERSION; + } + + const year = dateObj.getUTCFullYear(); + const month = pad2(dateObj.getUTCMonth() + 1); + const day = pad2(dateObj.getUTCDate()); + const hour = pad2(dateObj.getUTCHours()); + const minute = pad2(dateObj.getUTCMinutes()); + + return `v${year}${month}${day}.${hour}${minute}`; +} + +function normaliseBuildVersion(value) { + const version = String(value || '').trim(); + return BUILD_VERSION_PATTERN.test(version) ? version : ''; +} + +function resolveBuildVersion({ configuredVersion = '', date = new Date() } = {}) { + return normaliseBuildVersion(configuredVersion) || formatUtcBuildVersion(date); +} + +function readInjectedBuildVersion(globalObj = globalThis) { + if (globalObj === globalThis) { + return normaliseBuildVersion(globalThis.__ANYWAYDATA_BUILD_VERSION__); + } + return normaliseBuildVersion(globalObj?.__ANYWAYDATA_BUILD_VERSION__); +} + +function getBuildMetadata({ globalObj = globalThis } = {}) { + return { + version: readInjectedBuildVersion(globalObj) || FALLBACK_BUILD_VERSION, + }; +} + +export { + BUILD_VERSION_PATTERN, + FALLBACK_BUILD_VERSION, + formatUtcBuildVersion, + getBuildMetadata, + normaliseBuildVersion, + resolveBuildVersion, +}; diff --git a/packages/core-ui/js/gui_components/app/data-population-panel/index.js b/packages/core-ui/js/gui_components/app/data-population-panel/index.js index 2218bc2d..2590dca1 100644 --- a/packages/core-ui/js/gui_components/app/data-population-panel/index.js +++ b/packages/core-ui/js/gui_components/app/data-population-panel/index.js @@ -80,7 +80,11 @@ function createDataPopulationPanelComponent({ root, props = {}, services = {}, c if (syncFromText) { const isTextMode = view.getSchemaDefinition()?.getState?.()?.isTextMode === true; if (isTextMode) { - const parsed = view.getSchemaDefinition()?.syncFromText?.({ showErrors: true, force: true }) || { + const parsed = view.getSchemaDefinition()?.syncFromText?.({ + showErrors: true, + force: true, + refreshTextFromRows: true, + }) || { rows: [], errors: [], }; diff --git a/packages/core-ui/js/gui_components/generator/runtime/generator-schema-sync.js b/packages/core-ui/js/gui_components/generator/runtime/generator-schema-sync.js index d0c82a37..2e29b14e 100644 --- a/packages/core-ui/js/gui_components/generator/runtime/generator-schema-sync.js +++ b/packages/core-ui/js/gui_components/generator/runtime/generator-schema-sync.js @@ -12,7 +12,10 @@ function syncGeneratorSchemaRowsFromTextMode({ const isTextMode = state?.isTextMode === true; if (isTextMode) { - const parsed = schemaDefinition?.syncFromText?.({ showErrors, force: true }) || { rows: [], errors: [] }; + const parsed = schemaDefinition?.syncFromText?.({ showErrors, force: true, refreshTextFromRows: true }) || { + rows: [], + errors: [], + }; if (parsed.errors.length > 0) { if (showErrors) { surfaceSchemaError?.(formatSchemaErrors?.(parsed.errors) || ''); diff --git a/packages/core-ui/js/gui_components/shared/format-options-panel/format-option-panel-definition.js b/packages/core-ui/js/gui_components/shared/format-options-panel/format-option-panel-definition.js index 044a32c6..28cd3370 100644 --- a/packages/core-ui/js/gui_components/shared/format-options-panel/format-option-panel-definition.js +++ b/packages/core-ui/js/gui_components/shared/format-options-panel/format-option-panel-definition.js @@ -114,8 +114,8 @@ function renderField(field) { if (field.type === 'checkbox') { return `
    + ${help} @@ -132,8 +132,8 @@ function renderField(field) { field.type === 'selectCustom' ? `
    + @@ -142,8 +142,8 @@ function renderField(field) { : ''; return `
    + ${help} @@ -167,8 +167,8 @@ function renderField(field) { return `
    + ${help}

    +