From 1c8d24111fdb4f2dd6b37a6f8e034ece26fcc4dd Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 11:29:40 +0200 Subject: [PATCH 001/262] =?UTF-8?q?fix(form-engine):=20a=20card's=20one-li?= =?UTF-8?q?ne=20name=20is=20centred=20on=20its=20actions;=20a=20number=20t?= =?UTF-8?q?hat=20takes=20templates=20is=20shown=20as=20it=20is=20used=20?= =?UTF-8?q?=E2=80=94=200.10.61?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit David's review of qorus#646: - An open option card lined its name up with the top of its actions, so "When" sat above the buttons beside it. A heading of one line is centred on the actions; one with a description or tags under its name still lines up by its top. Injected Option Actions Edit Card checks it. - Number > Typed Freely typed a template into the bare number editor, which takes none, and read as a field that should offer templates and did not. It is the bare editor (12abc kept as typed, a decimal point typed before its digits kept), and says where a number that takes templates is. Template > Number Typed Freely is that field: the first click lists the templates, what is typed is kept as typed, and a chosen template is the value, named in the template picker. --- package.json | 2 +- src/components/form/engine/CompactRow.tsx | 5 ++- .../form/engine/FormEngineRemote.stories.tsx | 5 +++ .../form/fields/number/Number.stories.tsx | 15 +++---- .../fields/template/TemplateField.stories.tsx | 40 +++++++++++++++++++ 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index e99de2e4..a39626f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.60", + "version": "0.10.61", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 8c4a07b6..23fd9950 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -1673,9 +1673,12 @@ export const CompactRow = memo( $border={schemaIntentColor ? `${schemaIntentColor}66` : `${cInfo}66`} >
el.getBoundingClientRect()); + await expect(Math.abs(heading.top + heading.height / 2 - (actions.top + actions.height / 2))).toBeLessThanOrEqual(2); }, }; diff --git a/src/components/form/fields/number/Number.stories.tsx b/src/components/form/fields/number/Number.stories.tsx index 2b045edc..ea1b6f31 100644 --- a/src/components/form/fields/number/Number.stories.tsx +++ b/src/components/form/fields/number/Number.stories.tsx @@ -142,18 +142,19 @@ export const TypedFreely: Story = { docs: { description: { story: - 'Renders the Number field in integer mode after typing what is not a number: 12abc stays as typed (the form flags it, it is not cut to 12), and a template ($local:quantity) or an expression (@qty * 2) is passed on as typed for the field around it to take up.', + 'Renders the bare Number editor in integer mode, given no templates, after typing what is not a number: 12abc stays as typed (the form flags it, it is not cut to 12), and a decimal point typed before its digits is kept. A number field that takes templates is TemplateField around this editor: see Components/Form/Template › Number Typed Freely.', }, }, }, async play({ args, canvasElement }) { const canvas = within(canvasElement); const input = canvas.getByLabelText('Number'); - for (const text of ['12abc', '@qty * 2', '$local:quantity']) { - await userEvent.clear(input); - await userEvent.type(input, text); - await expect(input).toHaveValue(text); - await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(text), { timeout: 500 }); - } + await userEvent.clear(input); + await userEvent.type(input, '12abc'); + await expect(input).toHaveValue('12abc'); + await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('12abc'), { timeout: 500 }); + await userEvent.clear(input); + await userEvent.type(input, '10.'); + await expect(input).toHaveValue('10.'); }, }; diff --git a/src/components/form/fields/template/TemplateField.stories.tsx b/src/components/form/fields/template/TemplateField.stories.tsx index 8ddcd4fb..5531b9e7 100644 --- a/src/components/form/fields/template/TemplateField.stories.tsx +++ b/src/components/form/fields/template/TemplateField.stories.tsx @@ -254,6 +254,46 @@ export const NumberComponent: StoryObj = { }, }; +export const NumberTypedFreely: StoryObj = { + args: { + value: 25, + type: 'int', + allowTemplates: true, + componentFromType: true, + }, + parameters: { + docs: { + description: { + story: + 'Renders TemplateField for a whole number that takes templates. A click in the field lists the templates of its type at once; what is typed is kept as typed (12abc is flagged by the form, not cut to 12); a template chosen from the list is the value, shown by its name in the template picker.', + }, + }, + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = (await canvas.findByDisplayValue('25')) as HTMLInputElement; + // the first click lists the templates + await userEvent.click(input); + await waitFor(() => expect(document.querySelector('.reqore-popover-content .reqore-menu-item'), 'the list at the first click').toBeTruthy()); + // typed freely + await userEvent.clear(input); + await userEvent.type(input, '12abc'); + await expect(input).toHaveValue('12abc'); + // a template from the list: the field becomes the template picker, named by the template + await userEvent.clear(input); + const first = await waitFor(() => { + const item = document.querySelector('.reqore-popover-content .reqore-menu-item'); + expect(item, 'the list again').toBeTruthy(); + return item as HTMLElement; + }); + const name = first.querySelector('.reqore-menu-item-label, [data-reqore-marquee="text"]')?.textContent ?? first.textContent ?? ''; + await userEvent.click(first); + // the template is the value, named as the catalogue names it, in the template picker + await waitFor(() => expect(canvasElement.querySelector('input')).toBeNull()); + await expect(canvasElement.textContent).toContain(name.trim()); + }, +}; + export const AutoComponent: StoryObj = { args: { defaultType: 'auto', From a6e633d0cf5abbc974c638321f3300d60504f385 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 12:23:54 +0200 Subject: [PATCH 002/262] fix(expressions): an operand not filled in says to enter it, by its name; a wrong one says which, by its name David's review of qorus#646: a row rule built in the Visual view said "Value for argument 1 ("Value") is invalid: Missing value" twice while it was still being built. An empty operand now says "Enter a value for "Value"", and a wrong one ""Value" is invalid: ..." - by the name the editor shows, not its position in the call. --- __tests__/expressionArgumentRendering.test.tsx | 2 +- __tests__/expressionNullLiteral.test.ts | 2 +- __tests__/validation.test.ts | 5 +++-- __tests__/validationQorusTypes.test.ts | 3 ++- src/helpers/validations.ts | 16 ++++++++-------- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/__tests__/expressionArgumentRendering.test.tsx b/__tests__/expressionArgumentRendering.test.tsx index d1bec0b0..6f760339 100644 --- a/__tests__/expressionArgumentRendering.test.tsx +++ b/__tests__/expressionArgumentRendering.test.tsx @@ -78,7 +78,7 @@ describe('drawing an argument that is the null literal', () => { await settled(); expect(document.body.textContent).not.toContain('Something went wrong'); - expect(document.body.textContent).toContain('Missing value'); + expect(document.body.textContent).toContain('Enter a value for'); }); }); diff --git a/__tests__/expressionNullLiteral.test.ts b/__tests__/expressionNullLiteral.test.ts index fbb07844..63739df6 100644 --- a/__tests__/expressionNullLiteral.test.ts +++ b/__tests__/expressionNullLiteral.test.ts @@ -56,7 +56,7 @@ describe('an explicit null is a value', () => { // was never given a value, and must not be quietly accepted with it const missing = validate([{ type: 'any' }]); expect(missing.isValid).toBe(false); - expect(missing.reason).toContain('argument 1'); + expect(missing.reason).toBe('Enter a value for "any"'); }); it('names the argument the catalogue actually declares', () => { diff --git a/__tests__/validation.test.ts b/__tests__/validation.test.ts index 6b07b489..da3122e3 100644 --- a/__tests__/validation.test.ts +++ b/__tests__/validation.test.ts @@ -1295,7 +1295,8 @@ describe('expression with an empty operand slot', () => { expect(() => validateField('expression', value, { expressions: addition })).not.toThrow(); const result = validateFieldWithResult('expression', value, { expressions: addition }); expect(result.isValid).toBe(false); - expect(result.reason).toContain('argument 1'); + // said by the operand's name, and what to do + expect(result.reason).toMatch(/^Enter a value for "/); }); it('still accepts the same expression once the operand is filled', () => { @@ -1340,7 +1341,7 @@ describe('expression argument whose envelope carries no type', () => { }); expect(result.isValid).toBe(false); - expect(result.reason ?? '').toContain('argument 1'); + expect(result.reason ?? '').toMatch(/^"[^"]+" is invalid/); expect(result.reason ?? '').toContain('integer'); }); diff --git a/__tests__/validationQorusTypes.test.ts b/__tests__/validationQorusTypes.test.ts index 51dfbf51..bed52c47 100644 --- a/__tests__/validationQorusTypes.test.ts +++ b/__tests__/validationQorusTypes.test.ts @@ -841,7 +841,8 @@ describe('isFunction', () => { { isFunction: true, expressions } ); expect(result.isValid).toBe(false); - expect(result.reason).toContain('argument 2'); + // the empty second value, by its name + expect(result.reason).toMatch(/^Enter a value for "/); }); it('reports a missing operation', () => { diff --git a/src/helpers/validations.ts b/src/helpers/validations.ts index bb90b618..0078801a 100644 --- a/src/helpers/validations.ts +++ b/src/helpers/validations.ts @@ -1659,10 +1659,8 @@ export const _validateField = ( }); if (!result.isValid) { - return withContext( - result, - `Sub-expression for argument ${index + 1} ("${argLabel}") is invalid` - ); + // said by the operand's name, as the editor shows it - not by its position in the call + return withContext(result, `The expression in "${argLabel}" is invalid`); } continue; @@ -1686,10 +1684,12 @@ export const _validateField = ( }); if (!result.isValid) { - return withContext( - result, - `Value for argument ${index + 1} ("${argLabel}") is invalid` - ); + // an operand not filled in yet says what to do; one that is wrong says which and why (David's + // review of qorus#646: "Value for argument 1 ("Value") is invalid: Missing value" while building) + if (argValue?.value === undefined || argValue?.value === null || argValue?.value === '') { + return invalidResult(`Enter a value for "${argLabel}"`); + } + return withContext(result, `"${argLabel}" is invalid`); } } From fbe17dea12796e1f737ed5125930c939ab03b320 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 13:34:43 +0200 Subject: [PATCH 003/262] fix(form-engine): an open field held in its box says where it moves when it is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open field is held in the box it was opened in, so finishing an edit does not remount it in another box and take the focus. Held, it said nothing: a value already right sat under "Needs attention" until ✓ was pressed, with no reason given (David's review of qorus#646). It now says where it goes - 'Moves to "Set" when you close it (✓)' - under its editor while it is held, and nothing once it is closed or where it belongs. Compact Row Says Where It Moves fills a required field in Needs attention: the note appears, the editor stays open, and it goes once the field is closed. --- src/components/form/engine/CompactRow.tsx | 18 ++++++++ .../form/engine/FormEngine.stories.tsx | 43 +++++++++++++++++++ src/components/form/engine/FormEngine.tsx | 18 ++++++++ .../form/engine/compactRowContext.ts | 2 + 4 files changed, 81 insertions(+) diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 12f5400b..60b19648 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -166,6 +166,9 @@ export const richtextItemText = (item: unknown): string | undefined => richtextToString((item as { value?: Parameters[0] }).value) : undefined; +/** The status boxes by name, as their headers say them. */ +const BOX_NAMES = { attention: 'Needs attention', set: 'Set', optional: 'Optional' } as const; + export const CompactRow = memo( ({ optionName, @@ -210,6 +213,8 @@ export const CompactRow = memo( const isExpanded = useContextSelector(CompactRowContext, (v) => v.expandedOptions.includes(optionName) ); + // the box this open field moves to when it is closed, when that is not the one it is shown in + const movesTo = useContextSelector(CompactRowContext, (v) => v.heldOptions?.[optionName]); const isHighlighted = useContextSelector(CompactRowContext, (v) => v.highlightedOptions.includes(optionName) ); @@ -1245,6 +1250,7 @@ export const CompactRow = memo( const panelMessages = schemaMessages; const inlineMessages: TInfoMsg[] = [ ...fieldMessages, + ...(infoActive && schema?.default_value_desc ? [ { @@ -1587,6 +1593,18 @@ export const CompactRow = memo( {renderOption(optionName, optionField, 'small', true)} + {/* Why a value already right is still in this box: an open field stays where it was opened + (moving it would take the focus), and says where it goes when it is closed. */} + {movesTo ? + + {`Moves to "${BOX_NAMES[movesTo]}" when you close it (✓)`} + + : null}
{draftChip} diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index f40dd377..a872fc48 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -1078,6 +1078,49 @@ export const CompactRowCancelEdit: Story = { }, }; +export const CompactRowSaysWhereItMoves: Story = { + parameters: { + docs: { + description: { + story: + 'Renders a compact form with a required field left empty, in Needs attention. Opened and filled in, it stays where it is while it is open (moving it would take the focus), and says it moves to Set when it is closed; closed, it is in Set.', + }, + }, + }, + args: { + compact: true, + minColumnWidth: '360px', + value: {}, + options: { + cookie_name: { + type: 'string', + ui_type: 'string', + display_name: 'Cookie Name', + required: true, + }, + }, + }, + play: async ({ canvasElement }) => { + const row = () => canvasElement.querySelector('[data-field="cookie_name"]'); + await waitFor(() => expect(row()).toBeTruthy()); + fireEvent.click(row()!); + await waitFor(() => expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy()); + expect(canvasElement.textContent).not.toContain('Moves to'); + const input = canvasElement.querySelector( + '[data-field="cookie_name"] input, [data-field="cookie_name"] textarea' + ); + fireEvent.change(input!, { target: { value: 'session' } }); + // filled in, it says where it goes - and stays put, the editor still open + await waitFor(() => expect(canvasElement.textContent).toContain('Moves to "Set" when you close it (✓)'), { + timeout: 5000, + }); + expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy(); + fireEvent.click(canvasElement.querySelector('.options-readfirst-done')!); + await waitFor(() => expect(canvasElement.querySelector('.readfirst-row-editing')).toBeNull()); + expect(canvasElement.textContent).not.toContain('Moves to'); + }, +}; + export const CompactRowReadOnlyIsAWayIn: Story = { parameters: { /* Nothing to SEE here: what this story asserts is a role, a focus stop, a diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 9b898b85..ed8eb5de 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -3270,9 +3270,26 @@ const FormEngineImpl = ({ // The closure surface the extracted CompactRow reads through context. Refs and // setters are stable; the state/memo/handler fields change identity as they do // today, so a row re-renders exactly when its inputs do. + /* An open field is held in the box it was opened in, so finishing an edit does not remount it elsewhere + and take the focus (see `stableBucketOf`). Held, it says where it now belongs - otherwise a value + already right sat under "Needs attention" until ✓ was pressed, with nothing saying why (David's review + of qorus#646). Keyed by option name: the box it moves to when closed. */ + const heldOptionsKey = JSON.stringify( + Object.fromEntries( + expandedOptions + .map((name) => [name, settledBucket.current[name], getOptionBucket(name)] as const) + .filter(([, held, now]) => !!held && held !== now) + .map(([name, , now]) => [name, now]) + ) + ); + const heldOptions = useMemo>( + () => JSON.parse(heldOptionsKey), + [heldOptionsKey] + ); const compactRowContextValue = useMemo( () => ({ templates: templates.value, + heldOptions, readOnly, onReadOnlyActivate, commitMode, @@ -3322,6 +3339,7 @@ const FormEngineImpl = ({ }), [ templates.value, + heldOptions, readOnly, commitMode, expandMode, diff --git a/src/components/form/engine/compactRowContext.ts b/src/components/form/engine/compactRowContext.ts index 110ad90a..73534ce3 100644 --- a/src/components/form/engine/compactRowContext.ts +++ b/src/components/form/engine/compactRowContext.ts @@ -69,6 +69,8 @@ export interface ICompactRowContext { // display name in the read-first chip. templates?: IReqoreFormTemplates; expandedOptions: string[]; + /** Open fields held in the box they were opened in, with the box each moves to when it is closed. */ + heldOptions?: Record; highlightedOptions: string[]; flashedOptions: string[]; infoPanelOverrides: Record; From 4ee59108be950066815c5b57fd53e7fc9d269b35 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Wed, 7 Oct 2026 17:23:45 +0200 Subject: [PATCH 004/262] fix(expressions): the Text view of a visual expression shows holes, not hashes, and custom Text values as the templates they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From David's review of qorus#646. In a row rule, ">" with its first operand switched to Use Expression (nothing chosen) showed `undefined([object Object]) > [object Object]` in the Text view; with a custom Text value holding a field template on each side, `[object Object] > [object Object]`. The Text view handed `dpql/serialize` the AST as the Visual view stores it, and two operand shapes are not DPQL: an operand whose operation is not chosen yet, or that is not filled in, and a custom Text value, which is a rich-text document. The server writes them as hashes (`{args=(null)} > {type=int}`), and a stand-in as `[object Object]`. - An expression with a part not filled in is not serialized: the Text view shows a hole for each such part, `… > …`, as the row summary does. - A rich-text operand is written as what it holds: a lone template as that template (a field becomes `@field`), words as their string. Tests: expressionTextOfIncomplete (the two Text view cases fail before the fix); story ExpressionField · Text Mode Of An Unfinished Expression. --- __tests__/expressionTextOfIncomplete.test.tsx | 142 ++++++++++++++++++ .../expressions/ExpressionField.stories.tsx | 31 ++++ .../form/expressions/ExpressionField.tsx | 18 ++- .../form/expressions/textOfExpression.ts | 108 +++++++++++++ 4 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 __tests__/expressionTextOfIncomplete.test.tsx create mode 100644 src/components/form/expressions/textOfExpression.ts diff --git a/__tests__/expressionTextOfIncomplete.test.tsx b/__tests__/expressionTextOfIncomplete.test.tsx new file mode 100644 index 00000000..120ff022 --- /dev/null +++ b/__tests__/expressionTextOfIncomplete.test.tsx @@ -0,0 +1,142 @@ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render, screen, waitFor } from '@testing-library/react'; +import { forwardRef, useImperativeHandle } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +/** + * The Text view of an expression built in the Visual view (David's review of qorus#646). + * + * Two operand shapes are not DPQL: an operand switched to "Use Expression" whose operation is not + * chosen yet, and a custom Text value (a rich-text document). Handed to `dpql/serialize` as they were, + * the Text view showed `undefined([object Object]) > [object Object]` and `[object Object] > [object + * Object]`; the server writes them as hashes (`{args=(null)} > {type=int}`). + */ +const serialized: unknown[] = []; +vi.mock('../src/components/dpqlEditor', () => ({ + DpqlEditor: forwardRef(({ value }, ref) => { + useImperativeHandle( + ref, + () => ({ + serialize: async (expression: any) => { + serialized.push(expression); + // as the server writes a field operand and a string operand + const write = (arg: any): string => + typeof arg?.value === 'string' && /^\$record:\{(.+)\}$/.test(arg.value) + ? `@${/^\$record:\{(.+)\}$/.exec(arg.value)![1]}` + : typeof arg?.value === 'string' + ? JSON.stringify(arg.value) + : String(arg?.value ?? arg); + return `${write(expression.args[0])} ${expression.exp} ${write(expression.args[1])}`; + }, + }), + [] + ); + return ').querySelector('textarea'))).toBe(true); + expect(isTypingControl(cell('
').querySelector('div'))).toBe( + true + ); + }); + + it('does not read a checkbox, a switch, a radio or a button as typed into', () => { + expect(isTypingControl(cell('').querySelector('input'))).toBe(false); + expect( + isTypingControl(cell('').querySelector('input')) + ).toBe(false); + expect(isTypingControl(cell('').querySelector('input'))).toBe(false); + expect(isTypingControl(cell('').querySelector('button'))).toBe(false); + expect(isTypingControl(null)).toBe(false); + }); +}); diff --git a/package.json b/package.json index 1e69f0c5..d872bf98 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@chromatic-com/storybook": "^5.2.1", "@netsells/storybook-mockdate": "^0.3.3", "@qoretechnologies/qlip": "^0.1.3-beta.20260929120101", - "@qoretechnologies/reqore": "^0.77.2", + "@qoretechnologies/reqore": "^0.78.5", "@storybook/addon-docs": "^10.4.6", "@storybook/addon-links": "^10.4.6", "@storybook/addon-vitest": "^10.4.6", diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index 53dde562..5b65cb97 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -7495,9 +7495,11 @@ export const CompactAutoFocusNonFocusableFirstField: Story = { }, { timeout: 10000 } ); - // On the control itself, which is not a text box: that is the whole case. + // On the control itself, which is not a text box: that is the whole case. Since reqore 0.78 the switch + // is a native checkbox input. const active = document.activeElement as HTMLElement; - expect(['INPUT', 'TEXTAREA']).not.toContain(active.tagName); + expect(active.tagName).not.toBe('TEXTAREA'); + expect((active as HTMLInputElement).type).toBe('checkbox'); // It did not settle for the focusable text field further down instead. expect(document.querySelector('[data-field="name"]')?.contains(active)).toBe(false); }, @@ -7505,7 +7507,7 @@ export const CompactAutoFocusNonFocusableFirstField: Story = { // The first question most forms ask is "which kind?", and a pick-one is a set // of buttons: with ≤ 3 candidates the engine renders reqore checkboxes, which -// are focusable icons rather than inputs. The engine opened this row and the +// are native checkboxes (focusable icons before reqore 0.78), not text boxes. The engine opened this row and the // caret stayed on whatever opened the form. const CompactPickOneFirstSchema: Record = { kind: { @@ -7562,8 +7564,9 @@ export const CompactAutoFocusPickOneFirstQuestion: Story = { ); const active = document.activeElement as HTMLElement; // The control the row actually offers: a choice, not a text box. - expect(['INPUT', 'TEXTAREA']).not.toContain(active.tagName); - expect(active.getAttribute('tabindex')).toBe('0'); + // the first choice, a native checkbox since reqore 0.78 + expect(active.tagName).not.toBe('TEXTAREA'); + expect((active as HTMLInputElement).type).toBe('checkbox'); }, }; diff --git a/src/components/form/engine/rowFocus.ts b/src/components/form/engine/rowFocus.ts index 3a34809f..1cc200a9 100644 --- a/src/components/form/engine/rowFocus.ts +++ b/src/components/form/engine/rowFocus.ts @@ -15,9 +15,29 @@ * focusable element" would answer a text field with a button beside it. */ +/** + * Input types that take no typing. Since reqore 0.78 a checkbox, a switch and a radio are native inputs + * (they were focusable icons), and read as text entry they won over the text box beside them. + */ +const NOT_TYPED_INPUT_TYPES = [ + 'hidden', + 'checkbox', + 'radio', + 'button', + 'submit', + 'reset', + 'image', + 'file', + 'range', + 'color', +]; + /** Controls the reader types into. */ -const TEXT_ENTRY_SELECTOR = - 'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), [contenteditable="true"]'; +const TEXT_ENTRY_SELECTOR = [ + `input${NOT_TYPED_INPUT_TYPES.map((type) => `:not([type="${type}"])`).join('')}:not([disabled])`, + 'textarea:not([disabled])', + '[contenteditable="true"]', +].join(', '); /** Whether `element` is a control the reader types into: the focus there is theirs, not to be moved. */ export const isTypingControl = (element: Element | null | undefined): boolean => @@ -28,7 +48,7 @@ export const isTypingControl = (element: Element | null | undefined): boolean => * controls: a checkbox is an icon element made focusable, not an ``. */ const OTHER_CONTROL_SELECTOR = - 'button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + 'input:not([type="hidden"]):not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; /** * Subtrees inside the row's value cell that are NOT this field's editor: a diff --git a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx index f06d37f5..54f43d24 100644 --- a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +++ b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx @@ -955,9 +955,18 @@ export const AddValueSlot: Story = { await waitFor(() => expect(count('.expression-arg')).toBe(4), { timeout: 10000 }); const operands = document.querySelectorAll('.expression-arg'); const added = operands[operands.length - 1]; - await waitFor(() => expect(added.contains(document.activeElement)).toBe(true), { - timeout: 10000, - }); + /* The new operand takes the focus. Its first control is its template picker, a list that opens on focus, + and since reqore 0.78 one focused as it mounts opens at once: the caret is then in that list's search, + which is drawn outside the operand (a popover). */ + await waitFor( + () => { + const active = document.activeElement as HTMLElement | null; + expect( + !!active && (added.contains(active) || !!active.closest('.reqore-popover-content')) + ).toBe(true); + }, + { timeout: 10000 } + ); // Still after the last operand. expect(addSlot()!.nextElementSibling).toBeNull(); expect(context.args.onChange).toHaveBeenLastCalledWith( diff --git a/src/components/form/fields/boolean/Boolean.stories.tsx b/src/components/form/fields/boolean/Boolean.stories.tsx index 22b61602..39c06cc2 100644 --- a/src/components/form/fields/boolean/Boolean.stories.tsx +++ b/src/components/form/fields/boolean/Boolean.stories.tsx @@ -5,6 +5,10 @@ import { useState } from 'react'; import { StoryMeta } from '../../../../types'; import { BooleanFormField } from './Boolean'; +/** The switch as a user clicks it: since reqore 0.78 it is a native input under its drawn track. */ +const switchTrack = (input: HTMLElement): HTMLElement => + (input.closest('.reqore-checkbox') as HTMLElement | null) ?? input.parentElement!; + const meta = { component: BooleanFormField, title: 'Components/Form/Boolean', @@ -48,7 +52,7 @@ export const Checked: Story = { await expect(canvas.getByText('Yes')).toBeInTheDocument(); await expect(canvas.getByText('No')).toBeInTheDocument(); - await userEvent.click(canvas.getByLabelText('Boolean')); + await userEvent.click(switchTrack(canvas.getByLabelText('Boolean'))); await expect(args.onChange).toHaveBeenLastCalledWith(false); }, }; @@ -70,7 +74,7 @@ export const Unchecked: Story = { await expect(canvas.getByText('No')).toBeInTheDocument(); - await userEvent.click(canvas.getByLabelText('Boolean')); + await userEvent.click(switchTrack(canvas.getByLabelText('Boolean'))); await expect(args.onChange).toHaveBeenLastCalledWith(true); }, }; @@ -104,7 +108,7 @@ export const UnsetSelectsYes: Story = { async play({ canvasElement, args }) { const canvas = within(canvasElement); - await userEvent.click(canvas.getByLabelText('Boolean')); + await userEvent.click(switchTrack(canvas.getByLabelText('Boolean'))); await expect(args.onChange).toHaveBeenLastCalledWith(true); }, }; diff --git a/src/components/form/fields/markdown/Markdown.stories.tsx b/src/components/form/fields/markdown/Markdown.stories.tsx index 69cbc5f6..4f7f974e 100644 --- a/src/components/form/fields/markdown/Markdown.stories.tsx +++ b/src/components/form/fields/markdown/Markdown.stories.tsx @@ -35,7 +35,13 @@ export const Default: Story = { args: { 'aria-label': 'MarkdownEditor', }, + // A desktop: the preview is drawn beside the editor only above reqore's tablet width (1200px), and the + // story run's default viewport is 1200px wide. Reqore answers media queries in a story run since 0.78. + globals: { viewport: { value: 'desktop1440' } }, parameters: { + viewport: { + options: { desktop1440: { name: 'Desktop', styles: { width: '1440px', height: '900px' } } }, + }, docs: { description: { story: diff --git a/src/components/form/fields/radio-group/RadioGroup.stories.tsx b/src/components/form/fields/radio-group/RadioGroup.stories.tsx index 9434e8da..47e85516 100644 --- a/src/components/form/fields/radio-group/RadioGroup.stories.tsx +++ b/src/components/form/fields/radio-group/RadioGroup.stories.tsx @@ -52,8 +52,11 @@ export const Default: Story = { }, async play({ canvasElement, args }) { const canvas = within(canvasElement); + // a native radio since reqore 0.78, under the control a user clicks const java = canvas.getByLabelText('Java'); - await userEvent.click(java); + await userEvent.click( + (java.closest('.reqore-checkbox') as HTMLElement | null) ?? java.parentElement! + ); await expect(args.onChange).toHaveBeenLastCalledWith('Java'); }, }; diff --git a/src/components/form/fields/template/TemplateField.stories.tsx b/src/components/form/fields/template/TemplateField.stories.tsx index 6e2bf5ba..936c297d 100644 --- a/src/components/form/fields/template/TemplateField.stories.tsx +++ b/src/components/form/fields/template/TemplateField.stories.tsx @@ -955,6 +955,10 @@ const longExampleTemplates = { }; export const LongExampleValueTruncatedWithModal: StoryObj = { + /* A field in a narrow column, as a form's field is: the picker is as wide as the field (since reqore 0.78 it + follows the field's width as it is laid out), and the preview is cut at that width. Across a whole + desktop page the field is wider than the preview itself, and nothing has to be cut. */ + decorators: [(Story) =>
{Story()}
], args: { component: LongStringField, value: 'Some string', diff --git a/src/tracking/ui/ExperimentCard.stories.tsx b/src/tracking/ui/ExperimentCard.stories.tsx index 587c4580..1832e8f2 100644 --- a/src/tracking/ui/ExperimentCard.stories.tsx +++ b/src/tracking/ui/ExperimentCard.stories.tsx @@ -50,6 +50,37 @@ const buttonWith = (root: Element, text: string) => Array.from(root.querySelectorAll('button')).find((b) => b.textContent?.trim() === text); const row = (canvasElement: HTMLElement, key: string) => canvasElement.querySelector(`[data-version="${key}"]`) as HTMLElement; +/** The row of the version named `name`. */ +const rowNamed = (canvasElement: HTMLElement, name: string) => + rowsOf(canvasElement).find((r) => within(r).queryByText(name)) as HTMLElement; +/** + * A version's actions: on the card's width (under reqore's 480px for a row) Show analytics, Accept and + * Remove are one "Actions" menu at the end of the row's first line. Opens it, reads its items, closes it. + */ +const actionsOf = async (versionRow: HTMLElement) => { + const menuButton = versionRow.querySelector('.reqore-row-actions-menu-button'); + await expect(menuButton).toBeTruthy(); + await expect(menuButton!.getAttribute('aria-label')).toBe('Actions'); + await fireEvent.click(menuButton!); + const items = await waitFor(() => { + const found = Array.from( + versionRow.ownerDocument.querySelectorAll( + '.reqore-popover-content [role="menuitem"]' + ) + ); + expect(found.length).toBeGreaterThan(0); + return found; + }); + const read = items.map((item) => ({ + text: item.textContent?.trim() ?? '', + disabled: item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('disabled'), + item, + })); + return { + items: read, + close: () => fireEvent.keyDown(versionRow.ownerDocument.body, { key: 'Escape' }), + }; +}; // ── draft ────────────────────────────────────────────────────────────────────── @@ -62,15 +93,22 @@ const draft: Story = { await expect(c.getByText(/Not started yet/)).toBeInTheDocument(); await expect(rowsOf(canvasElement)).toHaveLength(2); await expect(c.getAllByText('Not started: no visitors yet')).toHaveLength(2); - // Remove is a bin icon at the far end of the row, labelled for assistive tech. - await expect(c.getByLabelText('Remove version Short copy')).toBeInTheDocument(); + // The row's actions are one menu: Show analytics, Accept (not before the test starts) and Remove. + const actions = await actionsOf(rowNamed(canvasElement, 'Short copy')); + await expect(actions.items.map((a) => a.text)).toEqual([ + 'Show analytics', + 'Accept', + expect.stringContaining('Remove version'), + ]); + await expect(actions.items[1].disabled).toBe(true); + await actions.close(); await expect(buttonWith(canvasElement, 'Remove')).toBeFalsy(); await expect(buttonWith(canvasElement, 'Start test')).toBeTruthy(); await expect(buttonWith(canvasElement, 'Pause')).toBeFalsy(); }, }; const DRAFT_TEXT = - 'Renders the card of a draft test with two versions: the "Draft" tag, the "not started" verdict, both rows without numbers, each with "Show analytics", Accept and a bin icon (Remove) at the far end, and Start test as the only solid button.'; + 'Renders the card of a draft test with two versions: the "Draft" tag, the "not started" verdict, both rows without numbers, each with its actions (Show analytics, Accept, Remove) in one Actions menu at the end of its first line, and Start test as the only solid button.'; export const Draft = variant(draft, DRAFT_TEXT, 'Dark'); export const DraftLight = variant(draft, DRAFT_TEXT, 'Light'); export const DraftPhone = variant(draft, DRAFT_TEXT, 'Phone'); @@ -88,14 +126,17 @@ const runningTwo: Story = { await expect(within(row(canvasElement, 'b')).getByText('Winner')).toBeInTheDocument(); await expect(within(row(canvasElement, 'a')).getByText('Original')).toBeInTheDocument(); await expect(row(canvasElement, 'b').dataset.highlight).toBe('winner'); - await expect(c.getAllByText('Show analytics')).toHaveLength(2); + for (const versionRow of rowsOf(canvasElement)) { + const actions = await actionsOf(versionRow); + await expect(actions.items[0].text).toBe('Show analytics'); + await actions.close(); + } await expect(c.queryByText('Shown')).toBeNull(); // The shown version is marked by its color and for assistive tech. await expect(row(canvasElement, 'a').getAttribute('aria-current')).toBe('true'); await expect( c.getByText(/1,228 visitors · 11.3% “Started a sign-up” · 91% chance to beat the original/) ).toBeInTheDocument(); - await expect(c.getByLabelText('Show analytics for Short copy')).toBeInTheDocument(); // The whole row of the other version shows it, by click and by keyboard. const rowB = row(canvasElement, 'b').querySelector('[role="button"]') as HTMLElement; await fireEvent.click(rowB); @@ -111,7 +152,7 @@ const runningTwo: Story = { }, }; const RUNNING_TWO_TEXT = - 'Renders a running test with two versions, best first: "Short copy", the likely winner, tagged "Winner" and lit by a green glow and wash, above the "Original". Tags are soft pills. Each row shows visitors, conversion, chance to beat and a bar, "Show analytics", Accept and a bin icon; the shown version is the blue row. Clicking the other row (or Enter on it) shows it; ✕ closes the card.'; + 'Renders a running test with two versions, best first: "Short copy", the likely winner, tagged "Winner" and lit by a green glow and wash, above the "Original". Tags are soft pills. Each row shows visitors, conversion, chance to beat and a bar, and its actions (Show analytics, Accept, Remove) in one Actions menu; the shown version is the blue row. Clicking the other row (or Enter on it) shows it; ✕ closes the card.'; export const RunningTwoVersions = variant(runningTwo, RUNNING_TWO_TEXT, 'Dark'); export const RunningTwoVersionsLight = variant(runningTwo, RUNNING_TWO_TEXT, 'Light'); export const RunningTwoVersionsPhone = variant(runningTwo, RUNNING_TWO_TEXT, 'Phone'); @@ -171,18 +212,21 @@ const concluded: Story = { await expect(await c.findByText('Ended')).toBeInTheDocument(); await expect(order(canvasElement)[0]).toBe('b'); await expect(within(row(canvasElement, 'b')).getByText('Winner')).toBeInTheDocument(); - // Each version keeps its analytics link after the test ended. - await expect(c.getByLabelText('Show analytics for Short copy')).toBeInTheDocument(); - await expect(c.getByLabelText('Show analytics for Current button')).toBeInTheDocument(); await expect(buttonWith(canvasElement, 'Start test')).toBeFalsy(); await expect(buttonWith(canvasElement, 'Stop')).toBeFalsy(); - Array.from(canvasElement.querySelectorAll('button')) - .filter((b) => b.textContent?.includes('Accept')) - .forEach((b) => expect(b).toBeDisabled()); + // Each version keeps its analytics link after the test ended; Accept and Remove are disabled. + for (const versionRow of rowsOf(canvasElement)) { + const actions = await actionsOf(versionRow); + await expect(actions.items[0]).toMatchObject({ text: 'Show analytics', disabled: false }); + for (const action of actions.items.slice(1)) { + await expect(action.disabled).toBe(true); + } + await actions.close(); + } }, }; const CONCLUDED_TEXT = - 'Renders an ended test whose winner (Short copy) is live: the "Ended" tag, the final verdict, the winner first with a "Winner" tag, the winner\'s glow and the blue shown color, "Show analytics" on every version, and no lifecycle buttons; Accept and Remove are disabled.'; + 'Renders an ended test whose winner (Short copy) is live: the "Ended" tag, the final verdict, the winner first with a "Winner" tag, the winner\'s glow and the blue shown color, Show analytics in every version\'s Actions menu, and no lifecycle buttons; Accept and Remove are disabled there.'; export const Concluded = variant(concluded, CONCLUDED_TEXT, 'Dark'); export const ConcludedLight = variant(concluded, CONCLUDED_TEXT, 'Light'); export const ConcludedPhone = variant(concluded, CONCLUDED_TEXT, 'Phone'); @@ -224,7 +268,9 @@ const notSetUp: Story = { b.textContent?.includes('Accept') ) ).toBe(false); - await expect(c.queryByLabelText(/Show analytics/)).toBeNull(); + // no actions at all, so no actions menu either + await expect(canvasElement.querySelector('.reqore-row-actions-menu-button')).toBeNull(); + await expect(c.queryByText(/Show analytics/)).toBeNull(); await expect(buttonWith(canvasElement, 'Open in admin portal')).toBeFalsy(); }, }; @@ -259,7 +305,9 @@ const removeConfirm: Story = { args: { test: RUNNING_FIVE }, play: async ({ canvasElement, args }) => { await card(canvasElement).findByText('Pricing page headline'); - await fireEvent.click(card(canvasElement).getByLabelText('Remove version Customer quote')); + const actions = await actionsOf(rowNamed(canvasElement, 'Customer quote')); + const remove = actions.items.find((a) => a.text.startsWith('Remove version')); + await fireEvent.click(remove!.item); const body = within(canvasElement.ownerDocument.body); await expect( await body.findByText('Remove “Customer quote” from the test?') @@ -270,7 +318,7 @@ const removeConfirm: Story = { }, }; const REMOVE_TEXT = - 'Renders a running five-version test after the bin icon was clicked on "Customer quote": the confirm step asks first and says the numbers restart; nothing is removed until it is confirmed.'; + 'Renders a running five-version test after Remove was chosen in the Actions menu of "Customer quote": the confirm step asks first and says the numbers restart; nothing is removed until it is confirmed.'; export const RemoveConfirmStep = variant(removeConfirm, REMOVE_TEXT, 'Dark'); export const RemoveConfirmStepLight = variant(removeConfirm, REMOVE_TEXT, 'Light'); export const RemoveConfirmStepPhone = variant(removeConfirm, REMOVE_TEXT, 'Phone'); diff --git a/yarn.lock b/yarn.lock index 9d2bcb21..6568ea95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1391,10 +1391,10 @@ resolved "https://registry.yarnpkg.com/@qoretechnologies/qlip/-/qlip-0.1.3-beta.20260929120101.tgz#f5debc1db06eb88278dd5c90f06b74882656eb83" integrity sha512-iFzAzB0+n+bqxfOtQpquAEPxwowBgLwV9IdGzvcA53UuOABQxJpWEEDmQQuab3x5Y47csBGe9GT7mH9Sm0uD7A== -"@qoretechnologies/reqore@^0.77.2": - version "0.77.2" - resolved "https://registry.yarnpkg.com/@qoretechnologies/reqore/-/reqore-0.77.2.tgz#6de61465e60f93ec870add8f44ab8cba90eb802b" - integrity sha512-do3k2r2n6yB7vnVIuiuGtFcTQ1iGDXWsS1xMkvFmWuMhu8G0Iir87gxNhpOzGkr1AkfR4/5P4Dc8hL8JnWBLUQ== +"@qoretechnologies/reqore@^0.78.5": + version "0.78.5" + resolved "https://registry.yarnpkg.com/@qoretechnologies/reqore/-/reqore-0.78.5.tgz#28c68f23fb052c99b37158f32e63f04b6f5a0b84" + integrity sha512-FIIJxsdtnzhczjnrjlgqZShHxHzwzXK2JgTCiKX8YhAg8RUcoO7xzUbBGBCiCBnUUpnPs4BdNew5KKk/v+L0cQ== dependencies: "@emotion/is-prop-valid" "^1.2.1" "@internationalized/date" "^3.5.3" @@ -1411,11 +1411,9 @@ react-aria-components "^1.2.0" react-hotkeys-hook "^5.3.2" react-icons "5.4.0" - react-overflow-list "^0.5.0" react-popper "^2.3.0" react-scrollbar "^0.5.6" react-use "^17.6.0" - react-use-pagination-hook "^1.1.2" react-window "^1.8.6" scheduler "^0.23.0" slate "^0.103.0" @@ -3248,11 +3246,6 @@ resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz" integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA== -"@types/js-cookie@^3.0.0": - version "3.0.6" - resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.6.tgz#a04ca19e877687bd449f5ad37d33b104b71fdf95" - integrity sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ== - "@types/js-yaml@^4.0.9": version "4.0.9" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" @@ -6114,11 +6107,6 @@ js-cookie@^2.2.1: resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz" integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== -js-cookie@^3.0.0: - version "3.0.8" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.8.tgz#444e6f4b27a5d844594fef61c9d6bca5f0787688" - integrity sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw== - js-sha3@0.8.0: version "0.8.0" resolved "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz" @@ -7815,7 +7803,7 @@ react-docgen@^8.0.0, react-docgen@^8.0.2: dependencies: scheduler "^0.27.0" -react-dom@^18.2.0, react-dom@^18.3.1: +react-dom@^18.3.1: version "18.3.1" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== @@ -7913,13 +7901,6 @@ react-motion@^0.5.2: prop-types "^15.5.8" raf "^3.1.0" -react-overflow-list@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/react-overflow-list/-/react-overflow-list-0.5.0.tgz#49ef8c34b619f2aead70b48dbf5d57b18d6a13aa" - integrity sha512-+UegukgQ10E4ll3txz4DJyrnCgZ3eDVuv5dvR8ziyG5FfgCDZcUKeKhIgbU90oyqQa21aH4oLOoGKt0TiYJRmg== - dependencies: - react-use "^17.3.1" - react-popper@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz" @@ -8002,35 +7983,6 @@ react-universal-interface@^0.6.2: resolved "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz" integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw== -react-use-pagination-hook@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/react-use-pagination-hook/-/react-use-pagination-hook-1.1.2.tgz" - integrity sha512-1acMTXIPH/1mut+0jkfgBJK+YkSO0EQwHdxi1Mv6jSDaK/gAKDXQHUCzeCUXBppiU5EAZ4qg5HbzAn951lZoaQ== - dependencies: - react "^18.2.0" - react-dom "^18.2.0" - web-vitals "^2.1.4" - -react-use@^17.3.1: - version "17.6.1" - resolved "https://registry.yarnpkg.com/react-use/-/react-use-17.6.1.tgz#c9692540f2230b763e42076fbf350e7e33ec2e21" - integrity sha512-uibb3pgzV4LFsYPHyXYGu7dD2+pyk/ZJlPH+AizBR3zolqPWyCleKcWWbUQaKSKfydwgnQ3ymGm1Ab3/saGHCA== - dependencies: - "@types/js-cookie" "^3.0.0" - "@xobotyi/scrollbar-width" "^1.9.5" - copy-to-clipboard "^3.3.1" - fast-deep-equal "^3.1.3" - fast-shallow-equal "^1.0.0" - js-cookie "^3.0.0" - nano-css "^5.6.2" - react-universal-interface "^0.6.2" - resize-observer-polyfill "^1.5.1" - screenfull "^5.1.0" - set-harmonic-interval "^1.0.1" - throttle-debounce "^3.0.1" - ts-easing "^0.2.0" - tslib "^2.1.0" - react-use@^17.6.0: version "17.6.0" resolved "https://registry.yarnpkg.com/react-use/-/react-use-17.6.0.tgz#2101a3a79dc965a25866b21f5d6de4b128488a14" @@ -8064,7 +8016,7 @@ react-window@^1.8.6: resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== -react@^18.2.0, react@^18.3.1: +react@^18.3.1: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== @@ -9641,11 +9593,6 @@ warning@^4.0.2: dependencies: loose-envify "^1.0.0" -web-vitals@^2.1.4: - version "2.1.4" - resolved "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz" - integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg== - webidl-conversions@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686" From 9cd612ccc71827725a83faa2538f68ede221d526 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 03:29:24 +0200 Subject: [PATCH 141/262] fix(form): a value edited back to one reported before is reported again (qorus#646) Since b1c83fb4, A then B then A again was not reported as A. In the Qorus IDE a template field was given a reference ("$record:{pos}"), then text after it (" Stk."), and then that text was deleted again. The host kept "$record:{pos} Stk.", its warning stayed, and Confirm stayed disabled. The IDE's host stores a field under its own type: what an `int` field reported comes back as a `string`. b1c83fb4 recognised a report coming back by comparing whole fields, type included, so the echo of B was not taken for the form's own report. That echo arrived in the same commit as the edit back to A. The edit was then held back as one made before a change of the host's, and the merge took B: the field went back to B, and A was never reported. A report is now recognised by its values only, field by field, with empty fields left out. The merge already compared fields that way. A change of the host's that alters only a type is taken for the echo it cannot be told from, which is harmless, since it holds the value the form already has. Tests: formEngineReportsAReturnToAnEarlierValue. Two cases fail before the fix: a template-style editor and a number field, each edited back to an earlier value as the echo of the other value arrives. A third case, where the echo arrives before the edit, passes before and after. --- ...ineReportsAReturnToAnEarlierValue.test.tsx | 147 ++++++++++++++++++ src/components/form/engine/FormEngine.tsx | 36 +++-- 2 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 __tests__/formEngineReportsAReturnToAnEarlierValue.test.tsx diff --git a/__tests__/formEngineReportsAReturnToAnEarlierValue.test.tsx b/__tests__/formEngineReportsAReturnToAnEarlierValue.test.tsx new file mode 100644 index 00000000..a401fc52 --- /dev/null +++ b/__tests__/formEngineReportsAReturnToAnEarlierValue.test.tsx @@ -0,0 +1,147 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { act, fireEvent, render } from '@testing-library/react'; +import { useCallback, useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FormEngine } from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +/** + * A value edited back to one the form reported before is reported again (qorus#646). + * + * A, then B, then A again: the Qorus IDE's template field, given a reference ("$record:{pos}"), then text + * after it (" Stk."), then that text deleted. The IDE's host stores a field under its own type (what an + * `int` field reported comes back as a `string`), so its echo of B was not recognised as the form's own + * report. The echo arrived in the same commit as the edit back to A, which was then held back as an edit + * made before the host's change, and the field went back to B: the host kept "$record:{pos} Stk.". + */ + +/** A field editor the test types into from outside: what it reports goes straight to the form. */ +const editor: { report?: (value: string) => void } = {}; +const ProbeText = ({ onChange }: { onChange: (value: string) => void }) => { + editor.report = onChange; + return null; +}; + +const host: { values: unknown[]; reported: unknown[] } = { values: [], reported: [] }; + +/** + * A host that stores each report under its own type (`string`), as the IDE does, and hands it back down + * `echoDelay` ms after it was told. + */ +const Host = ({ field, echoDelay }: { field: Record; echoDelay: number }) => { + const [value, setValue] = useState>({}); + host.values.push(value?.qty); + const onChange = useCallback( + (_name: string, next: Record) => { + host.reported.push(next?.qty?.value); + const stored = + next?.qty?.value === undefined ? {} : { qty: { type: 'string', value: next.qty.value } }; + setTimeout(() => setValue(stored), echoDelay); + }, + [echoDelay] + ); + return ( + + ); +}; + +const renderHost = async (field: Record, echoDelay: number) => { + host.values = []; + host.reported = []; + editor.report = undefined; + const view = render( + + + + + + ); + await pass(500); + return view; +}; + +/** Lets `ms` of fake time pass in 10 ms steps, each in an `act` of its own. */ +const pass = async (ms: number) => { + for (let elapsed = 0; elapsed < ms; elapsed += 10) { + await act(() => vi.advanceTimersByTimeAsync(10)); + } +}; + +const lastHeld = () => + (host.values[host.values.length - 1] as { value?: unknown } | undefined)?.value; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('a template field edited back to a value it reported before', () => { + const FIELD = { type: 'int', ui_type: 'probe-text', display_name: 'Qty', preselected: true }; + + it('reports it again when the echo of the other comes back with it', async () => { + await renderHost(FIELD, 50); + act(() => editor.report!('$record:{pos}')); + await pass(300); + act(() => editor.report!('$record:{pos} Stk.')); + // the echo of " Stk." arrives in the same commit as the edit back to the reference alone + act(() => { + vi.advanceTimersByTime(50); + editor.report!('$record:{pos}'); + }); + await pass(300); + + expect(host.reported[host.reported.length - 1]).toBe('$record:{pos}'); + expect(lastHeld()).toBe('$record:{pos}'); + }); + + it('reports it again when the echo came back before the edit', async () => { + await renderHost(FIELD, 50); + act(() => editor.report!('$record:{pos}')); + await pass(300); + act(() => editor.report!('$record:{pos} Stk.')); + await pass(300); + act(() => editor.report!('$record:{pos}')); + await pass(300); + + expect(host.reported[host.reported.length - 1]).toBe('$record:{pos}'); + expect(lastHeld()).toBe('$record:{pos}'); + }); +}); + +describe('a number field edited back to a value it reported before', () => { + const FIELD = { type: 'int', ui_type: 'int', display_name: 'Qty', preselected: true }; + + it('reports it again when the echo of the other comes back with it', async () => { + const { container } = await renderHost(FIELD, 150); + const input = () => container.querySelector('[data-field="qty"] input')!; + + fireEvent.change(input(), { target: { value: '5' } }); + await pass(500); + fireEvent.change(input(), { target: { value: '56' } }); + // 56 is reported 100 ms after it was typed, and its echo comes 150 ms after that + await pass(150); + // back to 5, reported 100 ms later: as the echo of 56 arrives + fireEvent.change(input(), { target: { value: '5' } }); + await pass(500); + + expect(host.reported[host.reported.length - 1]).toBe(5); + expect(lastHeld()).toBe(5); + }); +}); diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 5fefb2ed..fb7a58be 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -650,38 +650,40 @@ const normalizeEmptyFieldValues = (fields: TQorusForm | TQorusFlatForm | undefin ); /** - * The value as a host can hand it back: without the fields that hold nothing. A host that stores only the - * fields holding a value (the Qorus IDE's editors) hands a form's report back without its empty fields, and - * a report is recognised by its values, not by which empty fields came with it. Compared field for field - * (`normalizeEmptyFieldValues`), the report never came back as itself: the form took it for the host's own - * change and went back to it, losing what had been typed since, and the two chased each other's reports on - * every echo (qorus#646). + * The value as a host can hand it back: each field as its value, and the fields that hold nothing left out. + * + * A report of the form's is recognised by what it says, not by how the host writes it back. A host that + * stores only the fields holding a value (the Qorus IDE's editors) hands a report back without its empty + * fields; one that stores a field under its own type (the IDE stores what an `int` field reported as a + * `string`) hands it back with that type. Compared field for field, type and all, the report never came + * back as itself: + * - the form took it for the host's own change and went back to it, losing what had been typed since, and + * the two chased each other's reports on every echo; + * - an edit back to a value reported before (A, then B, then A again), made as the echo of B arrived, was + * held back as made before the host's change, and the field went back to B (qorus#646: a template + * field's text, deleted back to its reference, kept the text in the IDE). * * For recognising a value only. Whether a rebuild adds anything is still read with the empty fields in * (`isMirrorOfValue`): a new schema's empty required field is a row the form has to add. */ -const withoutEmptyFields = (fields: TQorusForm | TQorusFlatForm | undefined): TQorusForm => +const reportedValues = (fields: TQorusForm | TQorusFlatForm | undefined): Record => reduce( - (fields as TQorusForm) || {}, + (fields as Record) || {}, (acc, field, name) => { - const fieldValue = (field as IQorusFormField)?.value; - if (!( - field && - typeof field === 'object' && - (fieldValue === '' || fieldValue === undefined) - )) { - acc[name] = field as IQorusFormField; + const fieldValue = comparableField(field); + if (fieldValue !== undefined) { + acc[name] = fieldValue; } return acc; }, - {} as TQorusForm + {} as Record ); /** The same value, as a report coming back from a host is recognised. */ const sameReportedValue = ( a: TQorusForm | TQorusFlatForm | undefined, b: TQorusForm | TQorusFlatForm | undefined -): boolean => isEqual(withoutEmptyFields(a), withoutEmptyFields(b)); +): boolean => isEqual(reportedValues(a), reportedValues(b)); /** * Does this rebuild say anything the parent does not already hold? From aee0b91074d1f6e1486d0e32a448d3f91e3a6636 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 03:29:24 +0200 Subject: [PATCH 142/262] fix(form): an opened field not set yet shows its messages (qorus#646) A field the form holds no value for sits in the Optional box, and its row draws none of its messages there. That held after the row was opened too, so the editor showed no message either. In the Qorus IDE an event check's "Wait (ms)" is handed over disabled while "Until Step" is set, with a message naming the field to clear. Opening it showed the description and a disabled input, and not the message. It only seemed to work before b04b6707. The number field reported '' as it opened, which put the field in the form (a disabled one, at that), and a row in the form draws its messages. Since an untouched field reports nothing, the row stays a not-yet-set one. A text field never showed its message. A row not set yet now draws its messages while it is open. It still keeps quiet while it is closed. Tests: formEngineDisabledFieldKeepsItsMessage. Three cases fail before the fix: a disabled *int, int and string field with a message, opened from the Optional box, show the message above a disabled input and report nothing. The fourth case, a closed row keeps its message to itself, passes before and after. At e138a597 the number cases fail on the '' report and the string case on the missing message. --- ...ngineDisabledFieldKeepsItsMessage.test.tsx | 107 ++++++++++++++++++ src/components/form/engine/CompactRow.tsx | 6 +- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 __tests__/formEngineDisabledFieldKeepsItsMessage.test.tsx diff --git a/__tests__/formEngineDisabledFieldKeepsItsMessage.test.tsx b/__tests__/formEngineDisabledFieldKeepsItsMessage.test.tsx new file mode 100644 index 00000000..86deaafb --- /dev/null +++ b/__tests__/formEngineDisabledFieldKeepsItsMessage.test.tsx @@ -0,0 +1,107 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FormEngine } from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +const MESSAGE = 'Not available while Until Step is set. Clear Until Step to use this instead.'; + +/** + * A field the form has not been given a value for sits in the Optional box, and opening its row opens its + * editor with no value. The field's messages say why it cannot be used, so they belong in that editor. + * + * The Qorus IDE's event check (qorus#646): "Wait (ms)" cannot be set while "Until Step" is, so the IDE hands + * it over disabled, with a message naming the field to clear. Opening the row showed the description and a + * disabled input, and not the message. The message used to appear only because the number field reported '' + * as it opened, which put the field in the form; since an untouched field reports nothing (b04b6707), the row + * stayed a not-yet-set one, and such a row drew none of its messages, open or not. + */ +const mount = (type: string, onChange = vi.fn()) => + render( + + + + + + ); + +beforeEach(() => { + // the fields' pauses run on the test's clock; everything else runs as it would + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const row = (container: HTMLElement) => + container.querySelector('[data-field="timeout_ms"]'); + +/** Shows the Optional box's fields, which the compact form keeps folded. */ +const showOptional = async (container: HTMLElement) => { + const toggle = await waitFor(() => { + const found = Array.from( + container.querySelectorAll('.reqore-panel-title-label-row') + ).find((el) => /^Optional\d+$/.test(el.textContent ?? '')); + expect(found, 'no Optional box').toBeTruthy(); + return found as HTMLElement; + }); + fireEvent.click(toggle); + await waitFor(() => expect(row(container)).toBeTruthy()); +}; + +const openRow = async (container: HTMLElement) => { + fireEvent.click(row(container) as HTMLElement); + await waitFor(() => + expect(container.querySelector('[data-field="timeout_ms"] .readfirst-row-editing')).toBeTruthy() + ); +}; + +describe('a disabled field with a message, not yet set, in the Optional box', () => { + it.each([['*int'], ['int'], ['string']])( + 'a %s field shows its message when its row is opened', + async (type) => { + const onChange = vi.fn(); + const { container } = mount(type, onChange); + await showOptional(container); + await openRow(container); + + await waitFor(() => expect(row(container)?.textContent).toContain(MESSAGE)); + // the editor under it takes nothing, and opening it put nothing in the form + expect(row(container)?.querySelector('input[disabled], textarea[disabled]')).toBeTruthy(); + expect( + row(container)?.querySelector('input:not([disabled]), textarea:not([disabled])') + ).toBeNull(); + await act(() => vi.advanceTimersByTimeAsync(1000)); + expect(onChange).not.toHaveBeenCalled(); + } + ); + + it('keeps its message to itself while its row is closed', async () => { + const { container } = mount('*int'); + await showOptional(container); + expect(row(container)?.textContent).toContain('Wait (ms)'); + expect(row(container)?.textContent).not.toContain(MESSAGE); + }); +}); diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 64f9e1e6..801358f6 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -1223,7 +1223,11 @@ export const CompactRow = memo( // Info tiers: Tier 1 (danger/warning + dependency hints) must be visible // without interaction; Tier 2 (info/success, default notes) sits behind ⓘ. - const infoActive = !hidden; + /* A field not set yet keeps quiet in the Optional box, but not once it is opened: its editor is where + its messages are read, and one saying why the field cannot be used is the answer to opening it. Such + a row used to show them only when its editor reported '' on opening, which put the field in the form + (qorus#646: the Qorus IDE's "Wait (ms)", disabled while "Until Step" is set). */ + const infoActive = !hidden || isExpanded; /* `content` is said as it is: reqraft's own sentences quote values (`"__other__"`) that markdown would take for marks. Markdown a server wrote is made prose where it comes in (qorus#646). */ type TInfoMsg = { intent?: string; title?: string; content: string }; From 9d31b11b490dedd2b5b72d5f4db2504319b393cb Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 04:02:07 +0200 Subject: [PATCH 143/262] fix(expression-builder): a value added takes the focus, and opens no list (qorus#646) A value added with the "Add value" slot has no type yet, so it has nothing to type into: its first control is its template picker, a list that opens when it is focused. The builder gave that picker the focus, and since reqore 0.78 a list focused as it mounts opens at once, so adding a value opened the list of templates over the form. David decided that adding a value is not asking for that list. The builder now gives the focus to the new operand itself, which can take it (tabIndex -1). Its picker opens when the user goes to it: a click, or the keyboard, as before. An operand with a text field still gets the caret in that field. The Add Value Slot story accepted the focus in the open list since the 0.78.5 bump. It now expects the focus on the new operand and no list, and then opens the list with a click on its picker. Tests: - expressionAddedValueTakesTheFocus: a value added to concat, + and && takes the focus itself, and no list is open. All three fail before the fix (the picker had the focus, or for && nothing did). - Story Expression Builder > Add Value Slot: fails before the fix, where the focus is in the open list's search. --- ...expressionAddedValueTakesTheFocus.test.tsx | 80 +++++++++++++++++++ .../builder/ExpressionBuilder.stories.tsx | 28 +++---- .../expressions/builder/argumentWrapper.tsx | 2 + .../form/expressions/builder/index.tsx | 18 ++--- 4 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 __tests__/expressionAddedValueTakesTheFocus.test.tsx diff --git a/__tests__/expressionAddedValueTakesTheFocus.test.tsx b/__tests__/expressionAddedValueTakesTheFocus.test.tsx new file mode 100644 index 00000000..91532cc0 --- /dev/null +++ b/__tests__/expressionAddedValueTakesTheFocus.test.tsx @@ -0,0 +1,80 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/hooks/useStorage/useStorage', () => ({ + useReqraftStorage: (_k: string, d: unknown) => [d, vi.fn()], +})); + +import { Expression } from '../src/components/form/expressions/builder'; +import { mockExpressions } from '../src/components/form/expressions/mockExpressions'; +import { IExpression } from '../src/components/form/expressions/types'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +/** + * A value added with the "Add value" slot takes the focus, and opens nothing (qorus#646). + * + * A new operand has no type yet, so it has no text box: its first control is its template picker, a list that + * opens when it is focused. The builder gave that picker the focus, and since reqore 0.78 a list focused as it + * mounts opens at once, so adding a value opened the list of templates over the form. David: adding a value is + * not asking for that list. The operand itself takes the focus; its picker opens when the user goes to it. + */ +const Host = ({ initial, type }: { initial: IExpression; type: string }) => { + const [value, setValue] = useState(initial); + + return ( + setValue(next)) as never} + expressions={mockExpressions as never} + type={type as never} + returnType={type as never} + level={0} + /> + ); +}; + +const operands = () => document.querySelectorAll('.expression-arg'); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('a value added with the "Add value" slot', () => { + it.each([ + ['concat', 'string', 'first'], + ['+', 'int', 1], + ['&&', 'bool', true], + ])('in %s takes the focus itself, not its template picker', async (exp, type, value) => { + render( + + + + + + ); + const slot = await waitFor(() => { + const found = document.querySelector('.expression-add-arg'); + expect(found).toBeTruthy(); + return found as HTMLElement; + }); + + fireEvent.click(slot); + + await waitFor(() => expect(operands()).toHaveLength(2)); + const added = operands()[1]; + // a new value has no type yet, so nothing to type into: only its template picker + expect(added.querySelector('textarea, input')).toBeNull(); + expect(added.querySelector('.template-selector')).toBeTruthy(); + await waitFor(() => expect(document.activeElement).toBe(added)); + expect(document.querySelector('.reqore-popover-content')).toBeNull(); + }); +}); diff --git a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx index 54f43d24..25f1d4e7 100644 --- a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +++ b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx @@ -934,7 +934,7 @@ export const AddValueSlot: Story = { docs: { description: { story: - 'Renders the "concat" expression with the dashed "Add value" slot after the third operand — the only add control, no header icon. Clicking it appends a fourth, empty operand that takes focus, the slot moves after it, and onChange reports four args.', + 'Renders the "concat" expression with the dashed "Add value" slot after the third operand — the only add control, no header icon. Clicking it appends a fourth, empty operand that takes focus, the slot moves after it, and onChange reports four args. The new operand\'s template list stays closed until its picker is clicked, and then it opens.', }, }, }, @@ -953,20 +953,13 @@ export const AddValueSlot: Story = { await fireEvent.click(slot); await waitFor(() => expect(count('.expression-arg')).toBe(4), { timeout: 10000 }); - const operands = document.querySelectorAll('.expression-arg'); + const operands = document.querySelectorAll('.expression-arg'); const added = operands[operands.length - 1]; - /* The new operand takes the focus. Its first control is its template picker, a list that opens on focus, - and since reqore 0.78 one focused as it mounts opens at once: the caret is then in that list's search, - which is drawn outside the operand (a popover). */ - await waitFor( - () => { - const active = document.activeElement as HTMLElement | null; - expect( - !!active && (added.contains(active) || !!active.closest('.reqore-popover-content')) - ).toBe(true); - }, - { timeout: 10000 } - ); + /* The new operand takes the focus itself. It has no type yet, so nothing to type into: its first control + is its template picker, a list that opens when it is focused, and adding a value is not asking for that + list. It stays closed until the picker is used. */ + await waitFor(() => expect(document.activeElement).toBe(added), { timeout: 10000 }); + expect(document.querySelector('.reqore-popover-content')).toBeNull(); // Still after the last operand. expect(addSlot()!.nextElementSibling).toBeNull(); expect(context.args.onChange).toHaveBeenLastCalledWith( @@ -981,6 +974,13 @@ export const AddValueSlot: Story = { ); const reported = (context.args.onChange as ReturnType).mock.lastCall?.[0]; expect(reported.value.args).toHaveLength(4); + expect(document.querySelector('.reqore-popover-content')).toBeNull(); + + // The list opens when it is asked for. + await userEvent.click(added.querySelector('.template-selector')!); + await waitFor(() => expect(document.querySelector('.reqore-popover-content')).toBeTruthy(), { + timeout: 10000, + }); }, }; diff --git a/src/components/form/expressions/builder/argumentWrapper.tsx b/src/components/form/expressions/builder/argumentWrapper.tsx index 48bb6711..843030cd 100644 --- a/src/components/form/expressions/builder/argumentWrapper.tsx +++ b/src/components/form/expressions/builder/argumentWrapper.tsx @@ -205,6 +205,8 @@ export const ExpressionBuilderArgumentWrapper = memo( fluid={fluid} size='small' className='expression-arg' + // the builder gives a value just added the focus here when it has no text field (see `focusNewArgIn`) + tabIndex={-1} {...dragProps} // at most the width of the expression it is in: a narrow column shrinks its field instead style={{ flexShrink: 1, minWidth: 0, maxWidth: '100%', ...dragProps.style }} diff --git a/src/components/form/expressions/builder/index.tsx b/src/components/form/expressions/builder/index.tsx index 69a97059..e8120a7c 100644 --- a/src/components/form/expressions/builder/index.tsx +++ b/src/components/form/expressions/builder/index.tsx @@ -729,19 +729,11 @@ export const Expression = ({ return; } - // A typed operand mounts a text field; an untyped one opens on its - // template picker, whose trigger is the field's first button that is not - // one of the operand's own controls (grip, position, ⋮, remove). - const field = - added.querySelector('textarea, input') ?? - Array.from(added.querySelectorAll('button')).find( - (button) => - !button.closest( - '.expression-arg-drag-handle, .expression-arg-position, .template-more, .expression-remove-arg' - ) - ); - - field?.focus(); + /* A typed operand mounts a text field, and the caret goes there. An untyped one has nothing to type into: + its first control is its template picker, a list that opens when it is focused, and since reqore 0.78 + one focused as it mounts opens at once. Adding a value is not asking for that list (David, qorus#646), + so the operand itself takes the focus, and its picker opens when the user goes to it. */ + (added.querySelector('textarea, input') ?? added).focus(); }, [argCount]); const canAddArg = !!selectedExpression?.varargs && !readOnly; From 4d4121ddbfce3506baef4e8dbbb0049b08396a45 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 04:19:56 +0200 Subject: [PATCH 144/262] fix(form): a required list with no items says it is required (qorus#646) The form counts an empty list as not set: a required one is in "Needs attention", and an optional one is not saved (159b6d01). A row's messages counted it as a value, since an empty list is truthy, so they validated it, found nothing wrong, and said nothing: the row needed attention and did not say why, open or closed. An open list used to say "This field is required" only because the list field reported its list as it opened, and the form took that report for a cleared field. Since a field opened and left alone reports nothing (b04b6707), it said nothing there either. qlip showed it, in FormEngine's "Clear Value Only When There Is A Value". An empty list is now no value to the row's messages, as it is to the rest of the form, so a required one says "This field is required". Tests: formEngineRequiredEmptyListSaysSo. Three cases fail before the fix: a required empty list is said to be required by the messages, on its closed row and in its open editor. Two guards pass before and after: a required list with items, and an optional empty list, say nothing of a requirement. --- ...formEngineRequiredEmptyListSaysSo.test.tsx | 94 +++++++++++++++++++ .../form/engine/OptionFieldMessages.tsx | 7 +- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 __tests__/formEngineRequiredEmptyListSaysSo.test.tsx diff --git a/__tests__/formEngineRequiredEmptyListSaysSo.test.tsx b/__tests__/formEngineRequiredEmptyListSaysSo.test.tsx new file mode 100644 index 00000000..2d0a4235 --- /dev/null +++ b/__tests__/formEngineRequiredEmptyListSaysSo.test.tsx @@ -0,0 +1,94 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { FormEngine } from '../src/components/form/engine/FormEngine'; +import { getOptionFieldMessages } from '../src/components/form/engine/OptionFieldMessages'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +const REQUIRED_MESSAGE = 'This field is required'; + +/** + * A required list holding no items says "This field is required", as any other empty required field does + * (qorus#646). + * + * The form counts an empty list as not set: the row is in "Needs attention", and an optional one is not saved. + * The row's messages counted it as a value, since an empty list is truthy, so nothing said why the row needed + * attention. An open list used to say it only because the list field reported its list as it opened, and the + * form took that report for a cleared field; since an untouched field reports nothing (b04b6707), it said nothing + * open or closed. Found on qlip, in FormEngine's "Clear Value Only When There Is A Value". + */ +const SCHEMA = { + emptied: { + type: 'list', + ui_type: 'list', + element_type: 'string', + display_name: 'Emptied', + required: true, + }, + optional: { + type: 'list', + ui_type: 'list', + element_type: 'string', + display_name: 'Optional list', + }, +} as never; + +const messagesFor = (name: string, value: unknown) => + getOptionFieldMessages({ + schema: SCHEMA, + option: { type: 'list', value } as never, + name, + allOptions: {} as never, + getType: (type: string) => type, + } as never).map((message) => message.label); + +const renderForm = (props: Record = {}) => + render( + + + + + + ); + +const row = () => document.querySelector('[data-field="emptied"]'); + +describe('a required list with no items', () => { + it('is said to be required', () => { + expect(messagesFor('emptied', [])).toEqual([REQUIRED_MESSAGE]); + }); + + it('says so on its row, closed', async () => { + renderForm(); + await waitFor(() => expect(row()).toBeTruthy()); + expect(document.body.textContent).toContain('Needs attention'); + await waitFor(() => expect(row()?.textContent).toContain(REQUIRED_MESSAGE)); + }); + + it('says so in its editor, open', async () => { + renderForm({ initialExpandedOptions: ['emptied'] }); + await waitFor(() => expect(row()?.textContent).toContain('Add new item for "Emptied"')); + await waitFor(() => expect(row()?.textContent).toContain(REQUIRED_MESSAGE)); + }); +}); + +describe('what is not an empty required list says nothing of a requirement', () => { + it('a required list with items', () => { + expect(messagesFor('emptied', ['alpha'])).toEqual([]); + }); + + it('an optional list with no items', () => { + expect(messagesFor('optional', [])).toEqual([]); + }); +}); diff --git a/src/components/form/engine/OptionFieldMessages.tsx b/src/components/form/engine/OptionFieldMessages.tsx index adaf4117..e05cb88c 100644 --- a/src/components/form/engine/OptionFieldMessages.tsx +++ b/src/components/form/engine/OptionFieldMessages.tsx @@ -373,7 +373,12 @@ export const getOptionFieldMessages = ({ user's language, showed it beside the field's generic one, two messages for one problem (qorus#646). Required groups and dependencies still say what they say. */ const hostReason = (optionSchema as { invalid_reason?: string } | undefined)?.invalid_reason; - const hasValue = option.value || option.value === false || option.value === 0 || option.value === null; + /* An empty list is not a value: the form counts it as not set (an optional one is not saved, a required one + needs attention), so a required one is said to be required. Counted as a value, being truthy, it was + validated as one and passed, and nothing said why its row needed attention (qorus#646). */ + const hasValue = + (option.value || option.value === false || option.value === 0 || option.value === null) && + !(isArray(option.value) && option.value.length === 0); if (hostReason) { result.push({ label: hostReason, intent: 'danger' }); From a1b6d69d66a6849f99c8854800c89991e65d8fbb Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 04:57:27 +0200 Subject: [PATCH 145/262] fix(form): what the host changes beside a value is taken, its type included (qorus#646) Since 9cd612cc, the form recognises its own report coming back by values only, field by field, so a host that writes a field under a type of its own is not taken for changing it. That also hid a type the host did change. The Qorus IDE's sheet review makes an added field's whole number true or false, holding the same field pos: the host hands down the same pos, now typed bool. The form took it for its own report and kept the int, so the field opened on its Value tab with "pos" as text. A true or false field shows the field it holds on its Expression tab. The merge of a host change with the form's own edits (b1c83fb4) had the same gap: a field the form edited kept its own type over the one the host gave it. What the host changed beside a field's value since the value it handed down before - its type, whether it is an expression, its operator - is now taken, both when the host's value is the form's report coming back and when it is merged with an edit of the form's. What a host writes as it always has is not a change, so a host that stores a field under its own type still does not change the form's (the A, B, A case of 9cd612cc). Tests: formEngineTakesTheHostsTypeChange. - Three cases fail before the fix: a field made true or false by the host opens on its Expression tab; an edit of the form's merged with the host's type change keeps the edit and takes the type; a host type change in the same commit as an edit of the form's ends in the host's type and is never reported back as the old one. - The helper's cases: a changed type, expression flag and operator are taken, a dropped operator is dropped, a type the host always writes is not taken, and a bare value is left as it is. - Guards: the field opens on its Value tab as a whole number, and an edit is kept where the host changed nothing. formEngineReportsAReturnToAnEarlierValue and formEngineHostChangeCrossesAnEdit pass as before. --- ...formEngineTakesTheHostsTypeChange.test.tsx | 237 ++++++++++++++++++ src/components/form/engine/FormEngine.tsx | 75 +++++- 2 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 __tests__/formEngineTakesTheHostsTypeChange.test.tsx diff --git a/__tests__/formEngineTakesTheHostsTypeChange.test.tsx b/__tests__/formEngineTakesTheHostsTypeChange.test.tsx new file mode 100644 index 00000000..e2ca56d9 --- /dev/null +++ b/__tests__/formEngineTakesTheHostsTypeChange.test.tsx @@ -0,0 +1,237 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { useCallback, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + FormEngine, + mergeOwnEdits, + takeHostsAttributes, +} from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +/** + * A type the host changes is taken, even where the value the field holds stays the same (qorus#646). + * + * The Qorus IDE's sheet review: an added field is a whole number holding the record's field `pos`, and is then + * made true or false. The host hands down the same `pos`, now typed `bool`. Since 9cd612cc the form recognises + * its own report coming back by its values only, so it took that for its own report and kept the field an + * `int`: opened, the field showed `pos` as text on its Value tab, where a true or false field shows the field it + * holds on its Expression tab. + */ +const POS = '$data:pos'; + +const schemaFor = (type: string) => + ({ + value: { + type, + display_name: 'Value', + preselected: true, + supports_templates: true, + supports_expressions: true, + }, + }) as never; + +const valueFor = (type: string) => ({ value: { type, value: POS } }) as never; + +const form = (type: string, onChange = vi.fn()) => ( + + + + + +); + +const row = (container: HTMLElement) => + container.querySelector('[data-field="value"]'); +const activeTab = (container: HTMLElement) => + container.querySelector('.value-tab[aria-pressed="true"]')?.getAttribute('data-tab'); + +describe('a field whose type the host changes while its value stays the same', () => { + it('opens as the type the host gave it: a true or false field holding a field, on its Expression tab', async () => { + const { container, rerender } = render(form('int')); + await waitFor(() => expect(row(container)).toBeTruthy()); + + rerender(form('bool')); + + fireEvent.click(row(container) as HTMLElement); + await waitFor(() => expect(container.querySelector('.value-tab')).toBeTruthy()); + expect(activeTab(container)).toBe('expression'); + expect(container.querySelector('.value-tab-text')).toBeNull(); + }); + + it('as a whole number it opened on its Value tab, the field written in its text', async () => { + const { container } = render(form('int')); + await waitFor(() => expect(row(container)).toBeTruthy()); + + fireEvent.click(row(container) as HTMLElement); + await waitFor(() => expect(container.querySelector('.value-tab')).toBeTruthy()); + expect(activeTab(container)).toBe('value'); + expect(container.querySelector('.value-tab-text')).toBeTruthy(); + }); +}); + +describe('the host type change merged with an edit of the form', () => { + it('keeps the edit and takes the type', () => { + expect( + mergeOwnEdits( + { value: { type: 'bool', value: POS } } as never, + { value: { type: 'int', value: '$data:qty' } } as never, + { value: { type: 'int', value: POS } } as never + ) + ).toEqual({ value: { type: 'bool', value: '$data:qty' } }); + }); + + it('keeps the edit as it is where the host changed nothing', () => { + expect( + mergeOwnEdits( + { value: { type: 'int', value: POS } } as never, + { value: { type: 'int', value: '$data:qty' } } as never, + { value: { type: 'int', value: POS } } as never + ) + ).toEqual({ value: { type: 'int', value: '$data:qty' } }); + }); +}); + +describe('what the host changed beside a value, as the form takes it in', () => { + const ours = { value: { type: 'int', value: POS }, other: { type: 'string', value: 'x' } }; + + it('takes a type the host changed', () => { + expect( + takeHostsAttributes( + { value: { type: 'bool', value: POS } } as never, + ours as never, + { value: { type: 'int', value: POS } } as never + ) + ).toEqual({ ...ours, value: { type: 'bool', value: POS } }); + }); + + it('keeps its own type where the host writes the type it always writes', () => { + // a host that stores what an `int` field reports as a `string`, as the Qorus IDE's editors do + const same = takeHostsAttributes( + { value: { type: 'string', value: POS } } as never, + ours as never, + { value: { type: 'string', value: POS } } as never + ); + expect(same).toBe(ours); + }); + + it('takes whether the value is an expression, and an operator, the host changed', () => { + expect( + takeHostsAttributes( + { value: { type: 'int', value: POS, is_expression: true, op: ['>'] } } as never, + ours as never, + { value: { type: 'int', value: POS } } as never + ) + ).toEqual({ ...ours, value: { type: 'int', value: POS, is_expression: true, op: ['>'] } }); + }); + + it('drops an operator the host dropped', () => { + expect( + takeHostsAttributes( + { value: { type: 'int', value: POS } } as never, + { value: { type: 'int', value: POS, op: ['>'] } } as never, + { value: { type: 'int', value: POS, op: ['>'] } } as never + ) + ).toEqual({ value: { type: 'int', value: POS } }); + }); + + it('leaves a bare value as it is', () => { + const flat = { value: 5 }; + expect(takeHostsAttributes({ value: 6 } as never, flat as never, { value: 5 } as never)).toBe( + flat + ); + }); +}); + +/** A field editor the test types into from outside: what it reports goes straight to the form. */ +const editor: { report?: (value: string) => void } = {}; +const ProbeText = ({ onChange }: { onChange: (value: string) => void }) => { + editor.report = onChange; + return null; +}; + +type TField = { type: string; value: unknown }; +const host: { + draft: Record; + /** The type of `value` in every report, in order. */ + reportedTypes: (string | undefined)[]; + setDraft?: (draft: Record) => void; +} = { draft: {}, reportedTypes: [] }; + +/** Stores each field as it is reported, type and all, and hands it back down at once. */ +const TypedHost = ({ initial }: { initial: Record }) => { + const [draft, setDraft] = useState(initial); + host.draft = draft; + host.setDraft = setDraft; + const onChange = useCallback((_name: string, next: Record) => { + host.reportedTypes.push(next?.value?.type); + setDraft((previous) => + JSON.stringify(next) === JSON.stringify(previous) ? previous : { ...next } + ); + }, []); + return ( + ).value, + } as never + } + componentOverrides={{ 'probe-text': ProbeText }} + rendererOnlyUiTypes={['probe-text']} + initialExpandedOptions={['name']} + onChange={onChange as never} + /> + ); +}; + +describe('a host type change in the same commit as an edit in the form', () => { + it('is never reported back as the type it was', async () => { + host.reportedTypes = []; + editor.report = undefined; + render( + + + + + + ); + await waitFor(() => expect(editor.report).toBeTruthy()); + const before = host.reportedTypes.length; + + // the host makes the field true or false as the user's edit to the name reaches the form: one commit + act(() => { + host.setDraft!({ ...host.draft, value: { type: 'bool', value: POS } }); + editor.report!('flag'); + }); + + await waitFor(() => + expect(host.draft).toEqual({ + name: { type: 'string', value: 'flag' }, + value: { type: 'bool', value: POS }, + }) + ); + // the form's copy, made before the host's change, was not sent over it + expect(host.reportedTypes.slice(before)).not.toContain('int'); + }); +}); diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index fb7a58be..a8263c4e 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -736,6 +736,73 @@ const comparableField = (field: unknown): unknown => { const sameField = (a: unknown, b: unknown): boolean => isEqual(comparableField(a), comparableField(b)); +/** What a field says beside its value: its type, whether the value is an expression, its operator. */ +const FIELD_ATTRIBUTES = ['type', 'is_expression', 'op'] as const; + +/** A field written as `{ type, value }`, not a bare value. */ +const isTypedField = (field: unknown): field is IQorusFormField => + field !== null && + typeof field === 'object' && + !Array.isArray(field) && + typeof (field as IQorusFormField).type === 'string'; + +/** + * The form's copy of a field, with what the host changed beside its value since `base`: its type, whether it is + * an expression, its operator. `own` itself where the host changed none of them. + * + * A report of the form's is recognised by its values, and a field's value is merged by its value (see + * `reportedValues`, `mergeOwnEdits`), so a change the host made to a field's type alone said nothing: the form + * kept its own type. The Qorus IDE makes an added field's whole number true or false, holding the same field + * `pos`; the form kept the `int` and drew `pos` as text on the Value tab, where a true or false field shows it on + * its Expression tab (qorus#646). An attribute the host writes as it always has is not a change, so a host that + * stores a field under its own type does not change the form's. + */ +const withHostsAttributes = (own: unknown, host: unknown, base: unknown): unknown => { + if (!isTypedField(own) || !isTypedField(host)) { + return own; + } + let taken: Record | undefined; + FIELD_ATTRIBUTES.forEach((attribute) => { + const hosts = host[attribute]; + const before = isTypedField(base) ? base[attribute] : undefined; + if (isEqual(hosts, before) || isEqual(own[attribute], hosts)) { + return; + } + taken = taken ?? { ...own }; + if (hosts === undefined) { + delete taken[attribute]; + } else { + taken[attribute] = hosts; + } + }); + return taken ?? own; +}; + +/** + * The form's copy with what the host changed beside each field's value since `base` taken (see + * `withHostsAttributes`). Returns `ours` itself when there is nothing the form's copy does not have already. + */ +export const takeHostsAttributes = ( + theirs: TQorusForm | TQorusFlatForm | undefined, + ours: TQorusForm | TQorusFlatForm | undefined, + base: TQorusForm | TQorusFlatForm | undefined +): TQorusForm | TQorusFlatForm | undefined => { + let typed: Record | undefined; + forEach((theirs as Record) || {}, (host, name) => { + const own = (ours as Record | undefined)?.[name]; + const taken = withHostsAttributes( + own, + host, + (base as Record | undefined)?.[name] + ); + if (taken !== own) { + typed = typed ?? { ...(ours || {}) }; + typed[name] = taken; + } + }); + return (typed as TQorusForm | undefined) ?? ours; +}; + /** * The host's change, with the form's own edits kept: a three-way merge of `theirs` (the `value` the host * hands down now) and `ours` (the form's copy) against `base` (the `value` the form last saw). @@ -765,7 +832,8 @@ export const mergeOwnEdits = ( if (own === undefined) { delete merged[name]; } else { - merged[name] = own; + // the form's value, with what the host changed beside it (its type, say) + merged[name] = withHostsAttributes(own, host, before); } }); return (merged as TQorusForm | undefined) ?? theirs; @@ -2017,8 +2085,11 @@ const FormEngineImpl = ({ // the parent has to learn what the rebuild added mirroredValue.current = undefined; } + /* What the host changed beside a field's value is taken (its type, say): the report is recognised by its + values (see `takeHostsAttributes`). */ + const base = seenValue.current; setLocalValue((current) => { - const rebuilt = rebuild(current.fields); + const rebuilt = rebuild(takeHostsAttributes(value, current.fields, base) as TQorusForm); return isEqual(rebuilt, current.fields) ? current : { fields: rebuilt, meta: current.meta }; }); return; From 86127254d9d9aac328972da293812c8633a95725 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 05:14:57 +0200 Subject: [PATCH 146/262] fix(form): everything the host changes beside a value is taken, not a list of keys (qorus#646) a1b6d69d took a change the host made beside a field's value for the type, the expression flag and the operator: the keys a field holds today. Whatever else a host writes into a field beside its value, a change of it was still taken for the form's own report coming back, and lost, the way the type was. Every key of a field but its value is now compared three ways: a key the host changed since the value it handed down before is taken, a key it writes as it always has is not (so a host that stores a field under its own type still does not change the form's), and a key only the form writes is kept. The value is still what recognises a report, as a host may write a report back under its own type. Tests: formEngineTakesTheHostsTypeChange. A key other than those three, changed by the host, is taken: fails before the fix. A key only the form writes is kept: passes before and after. --- ...formEngineTakesTheHostsTypeChange.test.tsx | 21 +++++++++++++ src/components/form/engine/FormEngine.tsx | 31 ++++++++++--------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/__tests__/formEngineTakesTheHostsTypeChange.test.tsx b/__tests__/formEngineTakesTheHostsTypeChange.test.tsx index e2ca56d9..ca665e7f 100644 --- a/__tests__/formEngineTakesTheHostsTypeChange.test.tsx +++ b/__tests__/formEngineTakesTheHostsTypeChange.test.tsx @@ -146,6 +146,27 @@ describe('what the host changed beside a value, as the form takes it in', () => ).toEqual({ value: { type: 'int', value: POS } }); }); + it('takes anything else the host changed beside the value, as it would a type', () => { + expect( + takeHostsAttributes( + { value: { type: 'int', value: POS, kind: 'computed' } } as never, + ours as never, + { value: { type: 'int', value: POS, kind: 'constant' } } as never + ) + ).toEqual({ ...ours, value: { type: 'int', value: POS, kind: 'computed' } }); + }); + + it('keeps what only the form says beside a value, where the host never wrote it', () => { + const own = { value: { type: 'int', value: POS, note: 'the form' } }; + expect( + takeHostsAttributes( + { value: { type: 'int', value: POS } } as never, + own as never, + { value: { type: 'int', value: POS } } as never + ) + ).toBe(own); + }); + it('leaves a bare value as it is', () => { const flat = { value: 5 }; expect(takeHostsAttributes({ value: 6 } as never, flat as never, { value: 5 } as never)).toBe( diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index a8263c4e..3b077af2 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -736,9 +736,6 @@ const comparableField = (field: unknown): unknown => { const sameField = (a: unknown, b: unknown): boolean => isEqual(comparableField(a), comparableField(b)); -/** What a field says beside its value: its type, whether the value is an expression, its operator. */ -const FIELD_ATTRIBUTES = ['type', 'is_expression', 'op'] as const; - /** A field written as `{ type, value }`, not a bare value. */ const isTypedField = (field: unknown): field is IQorusFormField => field !== null && @@ -747,32 +744,38 @@ const isTypedField = (field: unknown): field is IQorusFormField => typeof (field as IQorusFormField).type === 'string'; /** - * The form's copy of a field, with what the host changed beside its value since `base`: its type, whether it is - * an expression, its operator. `own` itself where the host changed none of them. + * The form's copy of a field, with everything the host changed beside its value since `base`: its type, whether + * it is an expression, its operator, or anything else it says. `own` itself where the host changed nothing. * * A report of the form's is recognised by its values, and a field's value is merged by its value (see * `reportedValues`, `mergeOwnEdits`), so a change the host made to a field's type alone said nothing: the form * kept its own type. The Qorus IDE makes an added field's whole number true or false, holding the same field * `pos`; the form kept the `int` and drew `pos` as text on the Value tab, where a true or false field shows it on - * its Expression tab (qorus#646). An attribute the host writes as it always has is not a change, so a host that - * stores a field under its own type does not change the form's. + * its Expression tab (qorus#646). + * + * Every key but `value` is compared, not a list of the ones known today: whatever a host writes into a field + * beside its value, a change of it is the host's. A key the host writes as it always has is not a change, so a + * host that stores a field under its own type does not change the form's (that is why the value alone is what + * recognises a report). */ const withHostsAttributes = (own: unknown, host: unknown, base: unknown): unknown => { if (!isTypedField(own) || !isTypedField(host)) { return own; } + const hostField = host as unknown as Record; + const baseField = isTypedField(base) ? (base as unknown as Record) : {}; + const ownField = own as unknown as Record; let taken: Record | undefined; - FIELD_ATTRIBUTES.forEach((attribute) => { - const hosts = host[attribute]; - const before = isTypedField(base) ? base[attribute] : undefined; - if (isEqual(hosts, before) || isEqual(own[attribute], hosts)) { + new Set([...Object.keys(hostField), ...Object.keys(baseField)]).forEach((key) => { + const hosts = hostField[key]; + if (key === 'value' || isEqual(hosts, baseField[key]) || isEqual(ownField[key], hosts)) { return; } - taken = taken ?? { ...own }; + taken = taken ?? { ...ownField }; if (hosts === undefined) { - delete taken[attribute]; + delete taken[key]; } else { - taken[attribute] = hosts; + taken[key] = hosts; } }); return taken ?? own; From e4fc5528cc24841c4c396c793dda87ddfffd3fd5 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 14:22:07 +0200 Subject: [PATCH 147/262] fix(template): a reference reads as one chip in every view, drawn as soon as its catalogue can name it (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to how a template reference is drawn, from David's review of the sheet contract review. The chip was a different size in each view. The read summary drew it `tiny` with the prose's own font, the read-only chip drew it at the caller's size, and the expression editor drew it at the field's default size — so switching a value between its Visual and Expression views made the chip jump. All three now draw the same chip: a `tiny` box whose label takes the font of the text it sits in (`fontSize: 'inherit'`), so a reference is the same size wherever it is shown. A reference neither grammar matches — a DPQL `@create`, say — is recognised only by the catalogue that NAMES it, and the catalogue is fetched, so it is often not in hand at the editor's first render. The editor built its document once and rebuilt it only when the stored string changed, so a preset reference the catalogue had not yet named stayed raw text until the author re-picked it. The editor now re-chips the string in hand when what recognises its references arrives — the catalogue, or the field's own grammar — and only when a chip is actually gained or lost, so a document being edited is left as it is while nothing new is recognised. Tests: - templateChipSizeHarmonized: the read summary, the read-only chip and the expression editor chip all draw a tiny chip whose label takes the font it sits in. The read-only chip and the editor chip fail before the fix. - richTextChipsWhenCatalogueArrives: a reference is chipped when the catalogue arrives after the first render (fails before the fix); a reference the catalogue already names at the first render is chipped; a catalogue change that names nothing new leaves the document untouched (the cursor is not moved). --- ...richTextChipsWhenCatalogueArrives.test.tsx | 108 ++++++++++++++++++ __tests__/templateChipSizeHarmonized.test.tsx | 72 ++++++++++++ src/components/form/engine/CompactRow.tsx | 2 +- .../form/fields/rich-text/RichText.tsx | 36 ++++++ .../fields/template/ReadOnlyTemplateTag.tsx | 9 +- .../form/fields/template/TemplateField.tsx | 2 +- 6 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 __tests__/richTextChipsWhenCatalogueArrives.test.tsx create mode 100644 __tests__/templateChipSizeHarmonized.test.tsx diff --git a/__tests__/richTextChipsWhenCatalogueArrives.test.tsx b/__tests__/richTextChipsWhenCatalogueArrives.test.tsx new file mode 100644 index 00000000..00869444 --- /dev/null +++ b/__tests__/richTextChipsWhenCatalogueArrives.test.tsx @@ -0,0 +1,108 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A reference a string holds is drawn as a chip as soon as something can name it, even when the catalogue +// that names it arrives after the editor's first render (qorus#646). +// +// A reference neither grammar matches — a DPQL `@create`, say — is recognised only by the catalogue that +// NAMES it. The catalogue is fetched, so it is often not in hand at the first render. The editor built its +// document once and rebuilt it only when the stored string changed, so a preset reference stayed raw text +// until the author re-picked it. Here the catalogue arrives on a later render and the reference becomes a +// chip without the string changing. +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const editor = vi.hoisted(() => ({ props: undefined as Record | undefined })); + +vi.mock('@qoretechnologies/reqore/dist/components/RichTextEditor', () => ({ + ReqoreRichTextEditor: (props: Record) => { + editor.props = props; + return null; + }, +})); + +import { RichTextFormField } from '../src/components/form/fields/rich-text/RichText'; + +/** A reference only the catalogue can name: it matches neither reqraft's `$key:…` grammar nor a declared + * `$.`-token, so without the catalogue it is raw text. */ +const VALUE = '@create + 1'; +const EMPTY = { items: [] } as never; +const NAMED = { items: [{ label: 'Created order', value: '@create' }] } as never; + +const chipValues = (): string[] => + (editor.props?.value ?? []) + .flatMap((paragraph: any) => paragraph.children ?? []) + .filter((node: any) => node?.type === 'tag') + .map((node: any) => node.value); + +beforeEach(() => { + editor.props = undefined; +}); + +describe('a reference drawn as a chip once the catalogue can name it', () => { + it('chips the reference when the catalogue arrives after the first render', async () => { + const { rerender } = render( + + ); + // nothing names @create yet, so it is raw text + expect(chipValues()).toEqual([]); + + // the catalogue arrives; the stored string is unchanged + rerender( + + ); + + await waitFor(() => expect(chipValues()).toEqual(['@create'])); + }); + + it('chips a reference the catalogue already names at the first render', () => { + render( + + ); + + expect(chipValues()).toEqual(['@create']); + }); + + it('leaves the document alone when a catalogue change names nothing new', async () => { + const onChange = vi.fn(); + const { rerender } = render( + + ); + const before = editor.props?.value; + + rerender( + + ); + + // same document object: a rebuild that changes no chip must not replace it (that would move the cursor) + expect(editor.props?.value).toBe(before); + }); +}); diff --git a/__tests__/templateChipSizeHarmonized.test.tsx b/__tests__/templateChipSizeHarmonized.test.tsx new file mode 100644 index 00000000..70279acf --- /dev/null +++ b/__tests__/templateChipSizeHarmonized.test.tsx @@ -0,0 +1,72 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A template reference is drawn as the same chip wherever it is shown: the read summary (TemplateText), the +// read-only chip (ReadOnlyTemplateTag) and the expression editor (RichText) (qorus#646). +// +// The three drew the chip at different sizes — `size='tiny'` with the prose's own font, the caller's size, +// and the editor's default size — so switching a value between its Visual and Expression views made the chip +// jump. They now all draw a `tiny` chip whose label takes the font of whatever it sits in (`fontSize: +// 'inherit'`), so a reference is the same size in every view. +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const editor = vi.hoisted(() => ({ props: undefined as Record | undefined })); + +vi.mock('@qoretechnologies/reqore/dist/components/RichTextEditor', () => ({ + ReqoreRichTextEditor: (props: Record) => { + editor.props = props; + return null; + }, +})); + +import { RichTextFormField } from '../src/components/form/fields/rich-text/RichText'; +import { ReadOnlyTemplateTag } from '../src/components/form/fields/template/ReadOnlyTemplateTag'; +import { TemplateText } from '../src/components/form/fields/template/TemplateText'; + +const TEMPLATES = { items: [{ label: 'Customer Name', value: '$local:name' }] } as never; + +const chipFontSize = (container: HTMLElement): string | undefined => + container.querySelector('.reqore-tag')?.style.fontSize; + +beforeEach(() => { + editor.props = undefined; +}); + +describe('the same template reference chip in every view', () => { + it('the read summary chip takes the font it sits in', () => { + const { container } = render( + + + + ); + expect(chipFontSize(container)).toBe('inherit'); + }); + + it('the read-only chip takes the font it sits in', () => { + const { container } = render( + + + + ); + expect(chipFontSize(container)).toBe('inherit'); + }); + + it('the expression editor chip is a tiny chip that takes the font it sits in', () => { + render( + + ); + const [paragraph] = editor.props?.value; + const props = editor.props?.getTagProps(paragraph.children[1]); + expect(props.size).toBe('tiny'); + expect(props.style?.fontSize).toBe('inherit'); + }); +}); diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 9adb3ab7..f6e1bd17 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -562,7 +562,7 @@ export const CompactRow = memo( !(field as { is_expression?: boolean }).is_expression && (isValueTemplate(field.value) || !!findTemplate(ownTemplates ?? {}, field.value)) ) { - return ; + return ; } // A LIST whose elements are rich-text envelopes is a list of strings, so it diff --git a/src/components/form/fields/rich-text/RichText.tsx b/src/components/form/fields/rich-text/RichText.tsx index c54b3094..b497e811 100644 --- a/src/components/form/fields/rich-text/RichText.tsx +++ b/src/components/form/fields/rich-text/RichText.tsx @@ -111,6 +111,18 @@ export const getChipTooltipDescriptions = ( return out; }; +/* The chip references a document holds, in order, as one comparable string. Two documents that stand for + the same text but chip different references differ here; two that chip the same references do not — so a + re-parse that recognises nothing new is not taken for a change (which would move the cursor). */ +const chipValuesOf = (nodes?: ReadonlyArray<{ children?: ReadonlyArray> }>): string => + JSON.stringify( + (nodes ?? []).flatMap((paragraph) => + (paragraph?.children ?? []) + .filter((node) => node?.type === 'tag') + .map((node) => String(node?.value ?? '')) + ) + ); + export const RichTextFormField = memo(({ value, onChange, @@ -152,6 +164,23 @@ export const RichTextFormField = memo(({ } }, [JSON.stringify(value)]); + /* A reference in a string is drawn as a chip only once something can name it: the field's own grammar + (`templateToken`), or — for a reference neither grammar matches, a DPQL `@create` say — the catalogue + that NAMES it. Both can arrive AFTER the first render: the catalogue is fetched, and the effect above + rebuilds the document only when the stored string changes. So a preset reference the catalogue had not + yet named stayed raw text until the author re-picked it (qorus#646). Re-chip the string in hand when + what recognises its references changes — text mode only, and only when a chip is actually gained or + lost, so a document being edited is left exactly as it is while nothing new is recognised. */ + useEffect(() => { + if (!isText) { + return; + } + const rebuilt = templateTextToNodes(lastTextRef.current, templates, templateToken); + setLocalValue((current: any) => + chipValuesOf(current) === chipValuesOf(rebuilt) ? current : rebuilt + ); + }, [JSON.stringify(templates), String(templateToken)]); + useDebounce( () => { if (isText) { @@ -224,6 +253,10 @@ export const RichTextFormField = memo(({ leftIconProps: { image: metadata?.image }, label: templateChipLabel(templates, tagValue), tooltip: templateTooltip(templates, tagValue), + // The same chip the read summary (`TemplateText`) and the read-only tag draw: a `tiny` box whose + // label takes the font of the text it sits in, so a reference is the same size in every view. + size: 'tiny', + style: { fontSize: 'inherit' }, ...getTemplateTagStyle(metadata), }; } @@ -263,6 +296,9 @@ export const RichTextFormField = memo(({ } : descriptionByValue[tagValue] : undefined, + // The same chip the read summary and the read-only tag draw — see the `isText` branch above. + size: 'tiny', + style: { fontSize: 'inherit' }, ...getTemplateTagStyle(tag.metadata), }; } diff --git a/src/components/form/fields/template/ReadOnlyTemplateTag.tsx b/src/components/form/fields/template/ReadOnlyTemplateTag.tsx index 18a4a5ea..133f5ec9 100644 --- a/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +++ b/src/components/form/fields/template/ReadOnlyTemplateTag.tsx @@ -1,6 +1,5 @@ import { ReqoreTag, ReqoreTagGroup } from '@qoretechnologies/reqore'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; -import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes'; import { memo } from 'react'; import { getTemplateTagStyle, @@ -14,7 +13,6 @@ export interface IReadOnlyTemplateTagProps { value: string; /** Template catalogue, for resolving the display name + image + metadata. */ templates?: IReqoreFormTemplates; - size?: TSizes; } /** @@ -27,16 +25,19 @@ export interface IReadOnlyTemplateTagProps { * it's shown non-editably. */ export const ReadOnlyTemplateTag = memo( - ({ value, templates, size }: IReadOnlyTemplateTagProps) => { + ({ value, templates }: IReadOnlyTemplateTagProps) => { const { label, item } = describeTemplateReference(templates, value); const metadata = item?.metadata as TTemplateMeta | undefined; return ( - + // The same chip the read summary (`TemplateText`) and the expression editor draw: a `tiny` box whose + // label takes the font of the text it sits in, so a reference is the same size in every view. + diff --git a/src/components/form/fields/template/TemplateField.tsx b/src/components/form/fields/template/TemplateField.tsx index bdcd32fe..8a78e660 100644 --- a/src/components/form/fields/template/TemplateField.tsx +++ b/src/components/form/fields/template/TemplateField.tsx @@ -1595,7 +1595,7 @@ const TemplateFieldImpl = memo( // here; reqraft upgrades the read-only template to a proper picker chip — // the $-dollar icon + resolved display name + app image, coloured by the // IDE's intent scheme. Shared with the compact read-first row. - return ; + return ; } return ( From 2e3394668d4fc840c5858777a41daa0729df9261 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 14:22:07 +0200 Subject: [PATCH 148/262] chore: 0.10.63 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a39626f5..08afa904 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.61", + "version": "0.10.63", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts", From 62c577fdd22f3b08ea86d68ffe6622ccd33ca414 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 14:31:14 +0200 Subject: [PATCH 149/262] chore: 0.10.62 (develop base + 1) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 08afa904..ce6df993 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.63", + "version": "0.10.62", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts", From ac80cc871f6f418a5d9556edf3e9edb45e988e04 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 23:02:59 +0200 Subject: [PATCH 150/262] chore: 0.10.63 (develop is 0.10.62) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d872bf98..522a040a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.62", + "version": "0.10.63", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts", From 824e27b94c987e3d6f6dfe28d0e066e0d0ae2489 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Fri, 16 Oct 2026 23:42:33 +0200 Subject: [PATCH 151/262] fix(expressions): an operand's text value gives way to its row with a chip in it (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reference chip's label now takes the font of the text it sits in, so it reads the same size in the Visual and Expression views. In a condition narrowed to a 186px column, an operand's text value holding `$local:quantity` was then a pixel too wide: its box is held at least as wide as what it holds (TemplateField's minWidth 'fit-content', which a max-width cannot override), so the value pushed its own ⋮ menu out of the operand's row. In an operand's row, the operand's own value may now be narrower than what it holds; the chip is cut at the row's edge and its name stays on its hover. A field nested in another (an untyped value's) keeps its width: there the minimum is what holds a text value at 150px in a row with room. Story: Expression Builder > Operands Fit Narrow Column fails without the fix (the ⋮ 1px past its row at 186px); With Simple Value, which holds text values at 150px, passes before and after. --- .../form/expressions/builder/argumentWrapper.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/form/expressions/builder/argumentWrapper.tsx b/src/components/form/expressions/builder/argumentWrapper.tsx index 843030cd..7331a2a5 100644 --- a/src/components/form/expressions/builder/argumentWrapper.tsx +++ b/src/components/form/expressions/builder/argumentWrapper.tsx @@ -35,6 +35,16 @@ const StyledOperandFieldRow = styled(ReqoreControlGroup)` min-width: 0; max-width: 100%; } + + /* The operand's own value, written as text, gives way too, below the width of what it holds. Its box is + held at least that wide (TemplateField's minWidth 'fit-content'), and a reference chip's label takes the + font of the text it sits in (qorus#646), so a long one is wider than a narrow condition's column: the + value pushed its own ⋮ out of the row. Here it is cut at the row's edge instead; a chip's name is still + on its hover. Only the operand's own field: a field nested in another (an untyped value's) keeps its + width. !important: the box carries that minimum as an inline style. */ + & > .reqore-control-group > .reqore-panel { + min-width: 0 !important; + } `; export interface IExpressionBuilderArgumentWrapperProps { From 0b675cdd6644db3c9fee7f97f65d14404c1de186 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 01:03:18 +0200 Subject: [PATCH 152/262] fix(expressions): an operand's text value is as wide as what it holds, narrower only where its row is (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 824e27b9 let an operand's own text value be narrower than what it holds, so that a reference chip (its label now at the editor's font) no longer pushed the value's ⋮ out of a narrow condition's column. It did that by dropping the box's minimum, which TemplateField sets to what the box holds; the box's width stayed at 150px, so a reference wider than 150px less the clear button's room was cut on both sides in every operand row, not only in a narrow one: its icon and its × were out of view ("record: quantity", "Richtext Template", "Interface Name"). The box now takes its width from what it holds, with no minimum, so the row can shrink it, and the 150px is the editor's own minimum. In a row with room a value is 150px or the width of what it holds, as TemplateField means it to be; in a narrower row the box cuts the editor at its edge and the clear button stays in view. Story: Expression Builder > Reorder Keeps Template Operand checks that the reference's chip is whole in either slot (fails before the fix). --- .../builder/ExpressionBuilder.stories.tsx | 11 +++++++- .../expressions/builder/argumentWrapper.tsx | 25 ++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx index 25f1d4e7..61424f43 100644 --- a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +++ b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx @@ -1310,6 +1310,12 @@ export const AddValueSlotReadOnly: Story = { }, }; +/** The reference chips in the operands' fields drawn narrower than what they hold - their icon or × cut off. */ +const operandChipsCut = () => + [...document.querySelectorAll('.expression-arg .reqore-tag')] + .filter((chip) => chip.scrollWidth > chip.clientWidth + 1) + .map((chip) => chip.textContent?.trim()); + /** A template operand moved past a literal: each field shows the operand now in its slot. */ export const ReorderKeepsTemplateOperand: Story = { args: { @@ -1329,13 +1335,15 @@ export const ReorderKeepsTemplateOperand: Story = { docs: { description: { story: - 'Renders a "concat" (default surfaces: grip and ⋮ menu) whose first operand is the template reference "$local:some-richtext" and whose second is the literal "plain". "Move to end" on the template operand swaps them — the literal now sits first and the reference second, each in its own field, and nothing shows the reference twice.', + 'Renders a "concat" (default surfaces: grip and ⋮ menu) whose first operand is the template reference "$local:some-richtext" and whose second is the literal "plain". "Move to end" on the template operand swaps them — the literal now sits first and the reference second, each in its own field, and nothing shows the reference twice. The reference\'s chip is whole in its field in either slot: in a row with room, a field is as wide as what it holds.', }, }, }, play: async (context) => { await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 }); await waitForOrder(['$local:some-richtext', 'plain']); + // the reference's chip is whole: its field is not held at 150px when what it holds is wider + await waitFor(() => expect(operandChipsCut()).toEqual([]), { timeout: 10000 }); await openMoveMenu(0); await waitForText('Move to end'); @@ -1344,6 +1352,7 @@ export const ReorderKeepsTemplateOperand: Story = { // Without remounting the slots, slot 0 would keep its template state and // still show the reference while the literal showed nowhere. await waitForOrder(['plain', '$local:some-richtext']); + await waitFor(() => expect(operandChipsCut()).toEqual([]), { timeout: 10000 }); expect(context.args.onChange).toHaveBeenLastCalledWith( expect.objectContaining({ value: expect.objectContaining({ diff --git a/src/components/form/expressions/builder/argumentWrapper.tsx b/src/components/form/expressions/builder/argumentWrapper.tsx index 7331a2a5..e41ebde8 100644 --- a/src/components/form/expressions/builder/argumentWrapper.tsx +++ b/src/components/form/expressions/builder/argumentWrapper.tsx @@ -36,15 +36,28 @@ const StyledOperandFieldRow = styled(ReqoreControlGroup)` max-width: 100%; } - /* The operand's own value, written as text, gives way too, below the width of what it holds. Its box is - held at least that wide (TemplateField's minWidth 'fit-content'), and a reference chip's label takes the - font of the text it sits in (qorus#646), so a long one is wider than a narrow condition's column: the - value pushed its own ⋮ out of the row. Here it is cut at the row's edge instead; a chip's name is still - on its hover. Only the operand's own field: a field nested in another (an untyped value's) keeps its - width. !important: the box carries that minimum as an inline style. */ + /* The operand's own value, written as text: as wide as what it holds, and narrower than that only where + its row has less room. TemplateField draws it 150px wide and never narrower than what it holds + (minWidth 'fit-content'), and a min-width beats a max-width: a reference chip's label takes the font of + the text it sits in (qorus#646), so a long one is wider than a narrow condition's column, and the value + pushed its own ⋮ out of the row. So the box takes its width from what it holds, with no minimum, and + the row shrinks it; the 150px is the editor's own minimum, and where the row is narrower the box cuts + the editor at its edge (a chip's name is still on its hover) while its clear button stays in view. + Only the operand's own field: a field nested in another (an untyped value's) keeps its sizing. + !important: the box carries its width and minimum as inline styles. */ & > .reqore-control-group > .reqore-panel { + width: auto !important; min-width: 0 !important; } + + & + > .reqore-control-group + > .reqore-panel + > .reqore-panel-content + > .reqore-control-wrapper + > .reqore-textarea { + min-width: 150px; + } `; export interface IExpressionBuilderArgumentWrapperProps { From 58c58dfc616da3056a0a6b5699dcaad3d037e5e2 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 02:15:37 +0200 Subject: [PATCH 153/262] =?UTF-8?q?fix(descriptions):=20a=20touch=20screen?= =?UTF-8?q?'s=20=E2=93=98=20is=20the=20size=20of=20the=20choice=20beside?= =?UTF-8?q?=20it=20(qorus#646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foxhoundn on qlip #361 (Field › Choices Described On A Touch Screen): "That info button is massive - please reduce the size". The ⓘ that opens a choice's description on a touch screen was always Reqore's big control, 48px, beside checkboxes the field draws `tiny`, so it read as the main thing in the row. It now has no size of its own: it takes the size of the control group it is in, as the choice beside it does (`DescriptionInfoButton` passes the group's size on to its button). The same for the ⓘ among a collapsible section's header actions. A field's few choices are `small` on a touch screen, not `tiny`: a finger has to hit the box and its ⓘ, and BRAND_DESIGN §5 puts such a control at `small` or larger. With a mouse they stay `tiny`. Stories: Field › Choices Described On A Touch Screen checks that each ⓘ is as tall as its choice and no smaller than Reqore's small control (fails before: the ⓘ is 22px taller). SchemaDefinition › Sections Described On A Touch Screen checks the same minimum. --- src/components/Description/describedControl.tsx | 12 ++++++++---- src/components/form/fields/Field.stories.tsx | 14 +++++++++++--- src/components/form/fields/Field.tsx | 9 +++++++-- .../SchemaDefinitionField.stories.tsx | 6 ++++-- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/components/Description/describedControl.tsx b/src/components/Description/describedControl.tsx index dc719080..c1510fd1 100644 --- a/src/components/Description/describedControl.tsx +++ b/src/components/Description/describedControl.tsx @@ -1,6 +1,7 @@ // Copyright 2026 Qore Technologies, s.r.o. import { ReqoreButton, ReqoreControlGroup } from '@qoretechnologies/reqore'; import { IReqorePanelAction } from '@qoretechnologies/reqore/dist/components/Panel'; +import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes'; import { TReqoreTooltipProp } from '@qoretechnologies/reqore/dist/types/global'; import { cloneElement, Key, memo, ReactElement } from 'react'; import { descriptionTooltip, IDescriptionTooltipOptions } from '../../helpers/descriptionTooltip'; @@ -14,19 +15,23 @@ export interface IDescriptionInfoButtonProps { /** What it describes: the card's title and the button's accessible name. */ name: string; descriptions: IDescriptionTooltipOptions; + /** The size of the group it is in, which a control group hands its children: the control beside it has it too. */ + size?: TSizes; } /** * A finger's way to a control's description: a small ⓘ beside the control that opens its card on a tap. A tap on * the control itself does what the control does, and only that. + * + * It is the size of the control it sits beside: no size of its own, so it takes the group's, as the control does. + * A fixed big one (48px) beside a small checkbox read as the main thing in the row (Foxhoundn, qlip #361). */ export const DescriptionInfoButton = memo( - ({ description, name, descriptions }: IDescriptionInfoButtonProps) => ( + ({ description, name, descriptions, size }: IDescriptionInfoButtonProps) => ( ({ const { schema: finalArgSchema, loading: argSchemaLoading } = useArgSchema(arg_schema); // a choice's description card: drawn as markdown, opened by a tap on touch (`descriptionTooltip`) const descriptions = useDescriptionTooltips(); + /* The size of a field's few choices, each a checkbox with its ⓘ beside it on a touch screen: `tiny` for a mouse, + and at least Reqore's `small` for a finger, which `tiny` is too small to hit (BRAND_DESIGN §5). The ⓘ takes the + group's size, so a choice and its ⓘ are always one size. */ + const choiceSize: TSizes = descriptions.tapToOpen ? 'small' : 'tiny'; const handleChange = (value: TFormFieldValueType, event?: unknown) => { onChange(value, event); @@ -432,7 +437,7 @@ export const FormField = ({ if (element_allowed_values.length <= 3) { return ( - + {element_allowed_values.map((item, index) => { const itemValue = item.value?.value; const checked = serverValue.some((v) => isEqual(v, itemValue)); @@ -664,7 +669,7 @@ export const FormField = ({ // Non-creatable ≤3: checkboxes that track the current value if (allowed_values.length <= 3) { return ( - + {/* Headed by the field's own name; "Select one:" only when it has none. Same rule as FieldAllowedValuesCheckGroup. */} diff --git a/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx b/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx index ffca3898..d393ef7b 100644 --- a/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +++ b/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx @@ -1,3 +1,4 @@ +import { SIZE_TO_PX } from '@qoretechnologies/reqore/dist/constants/sizes'; import { StoryObj } from '@storybook/react-vite'; import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; import { useState } from 'react'; @@ -305,7 +306,7 @@ export const SectionsDescribedOnATouchScreen: Story = { docs: { description: { story: - "Renders the populated SchemaDefinition editor on a phone (390 px, touch), with the customers table open. A tap on its Primary Key tab only switches to it, and no card opens. The section's finger-sized ⓘ opens what Primary Key is for (\"Table primary key\"), which stays on the screen.", + "Renders the populated SchemaDefinition editor on a phone (390 px, touch), with the customers table open. A tap on its Primary Key tab only switches to it, and no card opens. The section's ⓘ, a size a finger can hit, opens what Primary Key is for (\"Table primary key\"), which stays on the screen.", }, }, }, @@ -331,7 +332,8 @@ export const SectionsDescribedOnATouchScreen: Story = { }); // the tab: switched, and nothing else await expect(document.querySelector('.reqore-popover-content')).toBeNull(); - await expect(info.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + // a finger can hit it: no smaller than Reqore's small control + await expect(info.getBoundingClientRect().height).toBeGreaterThanOrEqual(SIZE_TO_PX.small); await touch.tap(info); const card = await waitFor(() => { const shown = Array.from(document.querySelectorAll('.reqore-popover-content')).find( From 4688fc1e850e0c33bcdbe3a9660224adbc149e85 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 02:15:37 +0200 Subject: [PATCH 154/262] fix(smart-editor): Details sits beside the text and shows its card under itself (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foxhoundn on qlip #361 (SmartEditor › Hover Details On A Touch Screen): "The details are not very readable - covering the input - Add the details button in a group next to the input and render the info in a popover." On a touch screen the Details button sat under the editor, at the far end of the row, and what it showed was the mouse's hover: a glass popover at the caret, drawn over the text it explains. - The text and its Details button are one row (a control group that is always there, so the text is not remounted when the button appears). - Details anchors the hover under the button's right edge (`useLspHover.showAtCaret(at)`, which now also says which hover it showed), and that hover is drawn as a card of its own, on a surface it reads on, below the button and clear of the text. A mouse's hover is as it was. Story: SmartEditor › Hover Details On A Touch Screen checks the button is beside the text and the card does not cover it (fails before: the button is under the text). Unit: a hover asked for by Details is anchored where it is asked to be and says which hover it is (fails before). --- .../smartEditor/hoverDismissedAlone.test.tsx | 28 ++ .../smartEditor/SmartEditor.stories.tsx | 15 +- src/components/smartEditor/SmartEditor.tsx | 256 ++++++++++-------- src/components/smartEditor/useLspHover.ts | 100 ++++--- 4 files changed, 241 insertions(+), 158 deletions(-) diff --git a/__tests__/smartEditor/hoverDismissedAlone.test.tsx b/__tests__/smartEditor/hoverDismissedAlone.test.tsx index 3725af9f..ec9fbec4 100644 --- a/__tests__/smartEditor/hoverDismissedAlone.test.tsx +++ b/__tests__/smartEditor/hoverDismissedAlone.test.tsx @@ -99,6 +99,34 @@ describe('a hover dismissed with no hover named', () => { }); }); +describe('a hover asked for by Details', () => { + it('is anchored where it is asked to be, not at the caret, and says which hover it is', async () => { + const { result } = hookOnACaret(); + answer = FOR_EACH; + let shown: number | undefined; + // under the Details button: the card opens beside the text, not over it + await act(async () => { + shown = await result.current.showAtCaret({ left: 300, top: 60 }); + }); + + expect(result.current.hoverPosition).toEqual({ left: 300, top: 60 }); + expect(result.current.hoverContent).toEqual(FOR_EACH); + expect(shown).toBe(result.current.hoverId); + }); + + it('says it showed nothing where the server says nothing', async () => { + const { result } = hookOnACaret(); + answer = null; + let shown: number | undefined = -1; + await act(async () => { + shown = await result.current.showAtCaret({ left: 300, top: 60 }); + }); + + expect(shown).toBeUndefined(); + expect(result.current.hoverContent).toBeNull(); + }); +}); + describe('the caret on a word the server says nothing of', () => { it('shows no hover', async () => { const { result } = hookOnACaret(); diff --git a/src/components/smartEditor/SmartEditor.stories.tsx b/src/components/smartEditor/SmartEditor.stories.tsx index 823cedfb..a88b830f 100644 --- a/src/components/smartEditor/SmartEditor.stories.tsx +++ b/src/components/smartEditor/SmartEditor.stories.tsx @@ -810,7 +810,7 @@ export const HoverDetailsOnATouchScreen: Story = { docs: { description: { story: - 'Renders the SmartEditor on a phone (390 px, touch), taps it to put the caret in it and types "forEach". The tap opens no hover - a finger cannot hover, and the mouse events a browser emulates after a tap are not one, where they opened the hover the server gives anywhere beside the Details button. A tap on Details shows what the server says of the word at the caret ("forEach: invoke a callback for each element."), on the screen.', + 'Renders the SmartEditor on a phone (390 px, touch) with its Details button beside the text, taps the text to put the caret in it and types "forEach". The tap opens no hover - a finger cannot hover. A tap on Details shows what the server says of the word at the caret ("forEach: invoke a callback for each element.") in a card under the button, clear of the text.', }, }, }, @@ -853,7 +853,10 @@ export const HoverDetailsOnATouchScreen: Story = { }, { timeout: 10000 } ); - await expect(details.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + // beside the text, in its row: not under it, far from what it explains + const text = editable.getBoundingClientRect(); + await expect(details.getBoundingClientRect().left).toBeGreaterThanOrEqual(text.right - 1); + await expect(details.getBoundingClientRect().top).toBeLessThan(text.bottom); // the tap and the typing asked for no hover, and none was shown await expect(hoversAsked()).toHaveLength(0); await expect(hovers.shown).toEqual([]); @@ -871,6 +874,14 @@ export const HoverDetailsOnATouchScreen: Story = { { timeout: 10000 } ); await expect(shown.querySelector('strong')).toHaveTextContent('forEach'); + // a card of its own, clear of the text it explains + const card = shown.getBoundingClientRect(); + const covers = + card.left < text.right && + card.right > text.left && + card.top < text.bottom && + card.bottom > text.top; + await expect(covers).toBe(false); // asked for once, by Details, and shown once await expect(hoversAsked()).toHaveLength(1); await expect(hovers.shown).toHaveLength(1); diff --git a/src/components/smartEditor/SmartEditor.tsx b/src/components/smartEditor/SmartEditor.tsx index 38188761..26b8f939 100644 --- a/src/components/smartEditor/SmartEditor.tsx +++ b/src/components/smartEditor/SmartEditor.tsx @@ -24,6 +24,7 @@ import React, { useMemo, useReducer, useRef, + useState, } from 'react'; import { Editor, NodeEntry, Range, Transforms } from 'slate'; import { ReactEditor, RenderLeafProps } from 'slate-react'; @@ -214,6 +215,14 @@ SignatureHelpPill.displayName = 'SignatureHelpPill'; /** Distinguishes the completion lists of the editors on a page, for each editor's `aria-controls`. */ let completionMenuCounter = 0; +/* The text's box in its row: it takes the row, and gives it up to the Details button beside it on a touch + screen. */ +const EDITOR_BOX_STYLE: React.CSSProperties = { + position: 'relative', + flex: '1 1 auto', + minWidth: 0, +}; + export const SmartEditor = forwardRef( ( { @@ -290,6 +299,9 @@ export const SmartEditor = forwardRef(); + const detailsShown = detailsHoverId !== undefined && hover.hoverId === detailsHoverId; /* What the editor has reported and the host has not handed back yet. A host can hand an emit back after the next key is already in the editor - one that debounces, or a render that lands between @@ -534,124 +546,127 @@ export const SmartEditor = forwardRef {topActions} -
- - {showLoadingOverlay && ( -
- {overlayContent} -
- )} - - {hover.hoverContent && hover.hoverPosition && ( - // Non-interactive (`pointerEvents: none`) so typing/clicking - // doesn't dismiss it via a focus trap. - - -
- } - openOnMount - noArrow - placement='top' - handler='hover' - closeOnOutsideClick - minWidth='220px' - flat - transparent - backgroundBlur={20} - onToggleChange={(open: boolean) => { - // this hover only: a popover replaced by the next hover says it closed as it goes - if (!open) hover.clearHover(shownHoverId); + {/* The text and, on a touch screen, its Details button beside it, in one row: under the text the button + was far from it, and what it showed was drawn over the text (Foxhoundn, qlip #361). Always the row, + so the text is not remounted when the button appears. */} + +
+ - )} - {signatureHelp.signature && signatureHelp.position && ( - + {overlayContent} +
+ )} + - )} - - {showDetailsButton && ( - + {hover.hoverContent && hover.hoverPosition && ( + // Non-interactive (`pointerEvents: none`) so typing/clicking + // doesn't dismiss it via a focus trap. + + + + } + openOnMount + noArrow + /* A mouse's hover floats over the word it is of, as glass. What Details shows is a card of its + own, under the button and beside the text, on a surface it reads on. */ + placement={detailsShown ? 'bottom-end' : 'top'} + handler='hover' + closeOnOutsideClick + minWidth='220px' + {...(detailsShown ? {} : { flat: true, transparent: true, backgroundBlur: 20 })} + onToggleChange={(open: boolean) => { + // this hover only: a popover replaced by the next hover says it closed as it goes + if (!open) hover.clearHover(shownHoverId); + }} + /> + )} + {signatureHelp.signature && signatureHelp.position && ( + + )} + + {showDetailsButton && ( event.preventDefault()} - onClick={() => { - void hover.showAtCaret(); + onClick={(event: React.MouseEvent) => { + // anchored under the button's right edge: the card opens beside the text, not over it + const { right, bottom } = event.currentTarget.getBoundingClientRect(); + void hover.showAtCaret({ left: right, top: bottom }).then(setDetailsHoverId); }} > Details - - )} + )} +
{showDiagnostics && session.diagnostics.length > 0 && ( // One ReqoreMessage per diagnostic, severity → intent. Same // `session.diagnostics` source as the inline underline above. diff --git a/src/components/smartEditor/useLspHover.ts b/src/components/smartEditor/useLspHover.ts index 5ea7cdd8..ff87996a 100644 --- a/src/components/smartEditor/useLspHover.ts +++ b/src/components/smartEditor/useLspHover.ts @@ -82,6 +82,12 @@ export interface IUseLspHoverOptions { debounceMs?: number; } +/** Where a hover is anchored on the screen, in viewport coordinates. */ +export interface ILspHoverAnchor { + left: number; + top: number; +} + export interface IUseLspHoverResult { /** Markup content returned by the LSP, or `null` when no hover info. */ hoverContent: ILspMarkupContent | null; @@ -99,11 +105,12 @@ export interface IUseLspHoverResult { */ clearHover: (hoverId?: number) => void; /** - * Show what the server says of the word at the caret, anchored at the caret: the hover where there is no - * pointer to hover with (a finger's "Details", see `SmartEditor`). Nothing when the caret is nowhere or the - * server says nothing of it. + * Show what the server says of the word at the caret: the hover where there is no pointer to hover with (a + * finger's "Details", see `SmartEditor`). Anchored at `at` when given (the Details button, so the hover does + * not cover the text), else at the caret. Resolves to the `hoverId` of the hover shown; nothing when the caret + * is nowhere or the server says nothing of it. */ - showAtCaret: () => Promise; + showAtCaret: (at?: ILspHoverAnchor) => Promise; } /** @@ -271,40 +278,61 @@ export function useLspHover( // is stable across renders so it's not a dep here. }, [enabled, session.isReady, clearHover, editorRef]); - const showAtCaret = useCallback(async () => { - const editor = editorRef.current; - const client = session.client; - if (!enabled || !editor || !client || !session.isReady || !session.isContextReady || !editor.selection) { - return; - } - const point = editor.selection.focus; - const nodes = editor.children as ISlateElement[]; - const { line, character } = slatePointToLspPosition( - { path: point.path as number[], offset: point.offset }, - nodes, - converter - ); - let anchor: { left: number; top: number }; - try { - const rect = ReactEditor.toDOMRange(editor, editor.selection).getBoundingClientRect(); - anchor = { left: rect.left, top: rect.top }; - } catch { - return; - } - fetchHover.current.cancel(); - const reqId = ++requestIdRef.current; - try { - const content = await client.getHover(line, character); - if (reqId !== requestIdRef.current) return; // stale - if (!content) { + const showAtCaret = useCallback( + async (at?: ILspHoverAnchor) => { + const editor = editorRef.current; + const client = session.client; + if ( + !enabled || + !editor || + !client || + !session.isReady || + !session.isContextReady || + !editor.selection + ) { + return undefined; + } + const point = editor.selection.focus; + const nodes = editor.children as ISlateElement[]; + const { line, character } = slatePointToLspPosition( + { path: point.path as number[], offset: point.offset }, + nodes, + converter + ); + let anchor: { left: number; top: number }; + try { + const rect = ReactEditor.toDOMRange(editor, editor.selection).getBoundingClientRect(); + anchor = at ?? { left: rect.left, top: rect.top }; + } catch { + return undefined; + } + fetchHover.current.cancel(); + const reqId = ++requestIdRef.current; + try { + const content = await client.getHover(line, character); + if (reqId !== requestIdRef.current) return undefined; // stale + if (!content) { + clearHover(); + return undefined; + } + showHover(content, anchor); + return hoverIdRef.current; + } catch { clearHover(); - return; + return undefined; } - showHover(content, anchor); - } catch { - clearHover(); - } - }, [enabled, editorRef, session.client, session.isReady, session.isContextReady, converter, clearHover, showHover]); + }, + [ + enabled, + editorRef, + session.client, + session.isReady, + session.isContextReady, + converter, + clearHover, + showHover, + ] + ); return { hoverContent, hoverPosition, hoverId, clearHover, showAtCaret }; } From abc5f44bf781d8c5fc3112590cff10977e1ddc8c Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 02:18:29 +0200 Subject: [PATCH 155/262] chore: 0.10.63 - develop is 0.10.62 since #141 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d872bf98..522a040a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qoretechnologies/reqraft", - "version": "0.10.62", + "version": "0.10.63", "description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.", "main": "dist/index.js", "types": "dist/index.d.ts", From aac66a103fe847b085422e3053c136a3b2bfbb60 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 04:01:32 +0200 Subject: [PATCH 156/262] fix(form): a required field that arrives with the schema is the row the first-required scan opens (qorus#646) The first-required scan (expandFirstRequired / autoFocusFirstRequired) ran once, on the first render that had rows, and was spent whether it opened anything or not. It reads the form's value. A required field that the schema brings is added to that value by the sync-down, which is an effect, so it lands the render after the schema arrives. Read before that, the scan found nothing to open and was spent, and the field's row stayed closed. The Qorus IDE's pipeline processor form is one case: its required Field Mappings arrive with the schema, and the row never showed "Field mappings required". b1c83fb4 is what exposed this in the IDE. The IDE passes its value through fixOptions, so the value already holds the required field when the IDE hands the schema down. The form takes the schema in a render later (servedOptions follows the prop). Before b1c83fb4, the form took that value whole as the host's change in the earlier render, so the field was in the form's value by the time the scan saw the schema. Since b1c83fb4, a host value that differs from the form's last report only by empty fields counts as that report coming back, and the form adds the field from the schema itself, one render after the scan. Now: - the scan reads the form the schema makes. It waits while the value lacks a field the schema adds, and while the schema is not in force (behind the skeleton, or replaced by the host and not taken in yet); - the scan is keyed on a field's arrival. It looks at each field once, when the field first appears, and opens the first one that needs an answer. A schema that arrives in pieces is covered: a field in a later piece is opened when it arrives, as long as the scan has opened nothing and the user has not made their first move; - a field already in the form that comes to need an answer is still not opened. Tests: formEngineRequiredFieldArrivesWithItsSchema, 6 cases. - The host-shaped case (skeleton, value through fixOptions, both flags) fails at b1c83fb4 and passes at its parent b04b6707. - The two generic cases fail at both: the flaw is older than b1c83fb4. - The two negative cases fail under a mutation that un-keys the scan or stops a toggle from spending it. Story: FormEngine > Compact Required Field Arrives With Its Schema fails before the fix. --- ...RequiredFieldArrivesWithItsSchema.test.tsx | 179 ++++++++++++++++++ .../form/engine/FormEngine.stories.tsx | 77 ++++++++ src/components/form/engine/FormEngine.tsx | 75 +++++--- 3 files changed, 306 insertions(+), 25 deletions(-) create mode 100644 __tests__/formEngineRequiredFieldArrivesWithItsSchema.test.tsx diff --git a/__tests__/formEngineRequiredFieldArrivesWithItsSchema.test.tsx b/__tests__/formEngineRequiredFieldArrivesWithItsSchema.test.tsx new file mode 100644 index 00000000..25d71eef --- /dev/null +++ b/__tests__/formEngineRequiredFieldArrivesWithItsSchema.test.tsx @@ -0,0 +1,179 @@ +import { ReqoreUIProvider } from '@qoretechnologies/reqore'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { fixOptions, FormEngine } from '../src/components/form/engine/FormEngine'; +import { FetchContext } from '../src/contexts/FetchContext'; +import { emptyFetchContext } from './support/fetchContext'; + +const fetchContext = emptyFetchContext(); + +/** + * A required field that arrives with the schema is the row the first-required scan opens, though the form + * adds it to its value only one render after the schema (the rebuild for a new schema is an effect). + * + * The Qorus IDE's pipeline processor form (qorus#646): the value holds the processor, the schema arrives + * after the form mounts and makes a field mapping required; the scan ran on the value as it was before the + * rebuild, found nothing to open, and was spent - the mappings row stayed closed, its "Field mappings + * required" panel never shown. + */ +const PROCESSOR = { + type: 'string', + display_name: 'Processor Class', + required: true, + allowed_values: [ + { name: 'ProcessorTypeTest', value: 'ProcessorTypeTest' }, + { name: 'qogcompatibilitytest', value: 'qogcompatibilitytest' }, + ], + sort: 1, +}; + +const MAPPINGS = { + type: 'list', + ui_type: 'list', + display_name: 'Field Mappings', + required: true, + depends_on: ['processor'], + sort: 2, +}; + +const VALUE = { processor: { type: 'string', value: 'qogcompatibilitytest' } }; + +const Form = ({ + options, + value = VALUE, + onChange = vi.fn(), +}: { + options?: Record; + value?: Record; + onChange?: (value: unknown) => void; +}) => ( + + + + + +); + +/** + * The Qorus IDE's read-first options form: it waits behind the skeleton until the schema arrives, hands the + * value through `fixOptions` (so the value already holds the schema's required fields when the schema reaches + * the form, a render before the form takes the schema in) and asks for both the row and the caret. + */ +const HostForm = ({ schema }: { schema?: Record }) => ( + + + + + +); + +/** One object for "no schema yet", as a host keeps it, so a render without one does not hand the form a new one. */ +const NO_SCHEMA = {}; + +/** An expanded row renders as an edit card; a collapsed one as a read row. */ +const isExpanded = (field: string) => { + const el = document.querySelector(`[data-field="${field}"]`); + expect(el, `no row rendered for "${field}"`).toBeTruthy(); + return !el!.className.includes('readfirst-row'); +}; + +describe('a required field that arrives with the schema', () => { + it('is opened by the first-required scan, though the value holds it one render later', async () => { + const { rerender } = render(
); + + // nothing to draw yet: the schema has not arrived + expect(document.querySelector('[data-field="processor"]')).toBeNull(); + + rerender(); + + await waitFor(() => expect(document.querySelector('[data-field="mappings"]')).toBeTruthy()); + await waitFor(() => expect(isExpanded('mappings')).toBe(true)); + // the processor is set: it is not what needs an answer + expect(isExpanded('processor')).toBe(false); + }); + + it('is opened when the schema arrives in pieces and it is in a later one', async () => { + const { rerender } = render(); + + // the first piece needs no answer: nothing is opened + await waitFor(() => expect(document.querySelector('[data-field="processor"]')).toBeTruthy()); + expect(isExpanded('processor')).toBe(false); + expect(document.querySelector('[data-field="mappings"]')).toBeNull(); + + rerender(); + + await waitFor(() => expect(document.querySelector('[data-field="mappings"]')).toBeTruthy()); + await waitFor(() => expect(isExpanded('mappings')).toBe(true)); + }); + + it('is opened when the host adds it to the value itself and the form takes the schema in a render later', async () => { + const { rerender } = render(); + + rerender(); + + await waitFor(() => expect(document.querySelector('[data-field="mappings"]')).toBeTruthy()); + await waitFor(() => expect(isExpanded('mappings')).toBe(true)); + }); + + it('opens nothing when the schema it arrives with needs no answer', async () => { + const { rerender } = render(); + + rerender( + + ); + + await waitFor(() => expect(document.querySelector('[data-field="processor"]')).toBeTruthy()); + expect(isExpanded('processor')).toBe(false); + // an optional field is not added to the value, so it has no row + expect(document.querySelector('[data-field="mappings"]')).toBeNull(); + }); + + it('is not opened once the user has chosen where to work', async () => { + const { rerender } = render(); + + await waitFor(() => expect(document.querySelector('[data-field="processor"]')).toBeTruthy()); + // the user opens the processor's row + fireEvent.click(document.querySelector('[data-field="processor"]')!); + await waitFor(() => expect(isExpanded('processor')).toBe(true)); + + rerender(); + + await waitFor(() => expect(document.querySelector('[data-field="mappings"]')).toBeTruthy()); + expect(isExpanded('mappings')).toBe(false); + expect(isExpanded('processor')).toBe(true); + }); + + it('a field already in the form that comes to need an answer is not opened', async () => { + const options = { processor: PROCESSOR }; + const { rerender } = render(); + + await waitFor(() => expect(document.querySelector('[data-field="processor"]')).toBeTruthy()); + expect(isExpanded('processor')).toBe(false); + + // the host clears the processor: it now needs an answer, but it did not just arrive + rerender(); + + await waitFor(() => + expect(document.querySelector('[data-field="processor"]')!.textContent).not.toContain( + 'qogcompatibilitytest' + ) + ); + expect(isExpanded('processor')).toBe(false); + }); +}); diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index 97df4280..27b5a487 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -31,6 +31,7 @@ import { mockPopulatedDefinition } from '../fields/schema-definition/mockDefinit import { ITemplateFieldSavedValue } from '../fields/template/TemplateField'; import { defaultMarkdownRenderer } from '../fields/markdown/MarkdownView'; import { + fixOptions, FormEngine, IFormEngineGroup, IFormEngineProps, @@ -7633,6 +7634,82 @@ export const CompactAutoFocusLateMountingEditor: Story = { }, }; +// A required field that arrives with the schema, the Qorus IDE's pipeline processor: the processor is chosen, and +// the schema that arrives after the form mounts makes its field mappings required. The host waits behind the +// skeleton and hands the value through `fixOptions`, as the IDE's read-first options form does. +const ArrivingProcessorSchema: IOptionsSchema = { + processor: { + type: 'string', + display_name: 'Processor Class', + required: true, + allowed_values: [ + { name: 'ProcessorTypeTest', value: 'ProcessorTypeTest' }, + { name: 'qogcompatibilitytest', value: 'qogcompatibilitytest' }, + ], + sort: 1, + }, + mappings: { + type: 'list', + ui_type: 'list', + display_name: 'Field Mappings', + required: true, + depends_on: ['processor'], + sort: 2, + }, +} as unknown as IOptionsSchema; + +const ArrivingProcessorValue = { + processor: { type: 'string', value: 'qogcompatibilitytest' }, +} as unknown as IOptions; + +/** One object for "no schema yet", as a host keeps it. */ +const NoSchemaYet: IOptionsSchema = {}; + +/** A host whose schema arrives after the form mounts: on the render after the first, as an answer would. */ +const SchemaArrivesAfterMount = (args: IFormEngineProps) => { + const [schema, setSchema] = useState(); + useEffect(() => { + setSchema(ArrivingProcessorSchema); + }, []); + const value = useMemo(() => fixOptions(ArrivingProcessorValue, schema) as IOptions, [schema]); + + return ; +}; + +export const CompactRequiredFieldArrivesWithItsSchema: Story = { + parameters: { + docs: { + description: { + story: + 'Renders a compact form whose schema arrives after it mounts and brings a required field, Field Mappings, that the value does not hold yet. When the schema arrives, the Field Mappings row opens by itself, and the Processor Class row, already set, stays closed.', + }, + }, + }, + args: { + compact: true, + minColumnWidth: '300px', + autoFocusFirstRequired: true, + expandFirstRequired: true, + }, + render: (args) => , + play: async () => { + await waitFor(() => expect(document.querySelector('[data-field="mappings"]')).toBeTruthy(), { + timeout: 10000, + }); + // an open row is drawn as its edit card, a closed one as its read row + await waitFor( + () => + expect( + document.querySelector('[data-field="mappings"]')!.className.includes('readfirst-row') + ).toBe(false), + { timeout: 10000 } + ); + expect( + document.querySelector('[data-field="processor"]')!.className.includes('readfirst-row') + ).toBe(true); + }, +}; + // A richtext value whose prose is interleaved with template chips — the shape an // alert rule's message body has. The read-first summary renders the chips inline // with the surrounding words, so the two have to sit on the same baseline. diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index 3b077af2..d2c6d8f1 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -1311,12 +1311,15 @@ export interface IFormEngineProps extends Omit>(new Set()); const toggleExpandedOption = useCallback( (optionName: string) => { @@ -3004,9 +3012,10 @@ const FormEngineImpl = ({ }, [initialExpandedOptions, compact, availableOptions]); // --- First-attention-field autofocus (opt-in) ----------------------------- - /** The first row that needs an answer and can take one, if any: what the first-required scan opens. */ - const firstAttentionRow = (): string | undefined => - getFirstAttentionOptionName(Object.keys(availableOptions), (fieldName) => { + /** The first row (of `names`, in order: every row by default) that needs an answer and can take one, if any: what + * the first-required scan opens. */ + const firstAttentionRow = (names: string[] = Object.keys(availableOptions)): string | undefined => + getFirstAttentionOptionName(names, (fieldName) => { const schema = options?.[fieldName]; if (!schema) { return undefined; @@ -3048,12 +3057,11 @@ const FormEngineImpl = ({ // `expandedOptions` — the row's editor-focus effect does the rest. No DOM // scraping, no synthetic clicks, no polling. // - // Strictly one-shot: it fires the first time the form has focusable content - // (so an async-loaded schema is still covered — the empty first pass doesn't - // count), then never again for the life of this instance. A server-driven - // field update or an in-place schema reload will NOT re-grab focus; only a - // genuine remount (a fresh instance) auto-focuses again, which is the intended - // on-mount behaviour. + // It opens one row for the life of this instance, keyed on a field's arrival: the first field that needs an + // answer when it first appears in the form. A schema that arrives after the mount, or in pieces, is covered: the + // fields of a later piece are looked at when they arrive, as long as the scan has opened nothing yet. A field + // already in the form that comes to need an answer (a value edited, a server-driven update) is not a reason to + // open it, nor to re-grab focus; only a genuine remount (a fresh instance) arms the scan again. // // It waits only for the user's first move. A form opened from a button mounts while that button holds the // focus, and the scan waits for it to be free - but the user does not wait: they open a row (which spends @@ -3068,7 +3076,8 @@ const FormEngineImpl = ({ // CompactRow's tap-to-edit rule, and it declines just as firmly to take one // that is already somewhere outside the row. const wantsFocus = !!autoFocusFirstRequired; - if ((!wantsFocus && !expandFirstRequired) || !compact || !options) { + // the schema the form holds is the one in force: not one a skeleton stands for, nor one the host has replaced + if ((!wantsFocus && !expandFirstRequired) || !compact || !options || !schemaInForce) { return; } if (hasAutoFocusedRef.current) { @@ -3076,7 +3085,17 @@ const FormEngineImpl = ({ } const orderedNames = Object.keys(availableOptions); - if (!orderedNames.length) { + // the fields that have appeared since the scan last looked: only they can be what it opens + const arrived = orderedNames.filter((name) => !scannedFieldsRef.current.has(name)); + if (!arrived.length) { + return; + } + /* Read on the form the schema makes: a required field the schema brings is added to the form's value by the + sync-down, an effect, so the render after the schema arrives. Read before that, the form had no row for it: + the scan found nothing to open and was spent, and the field's row stayed closed (qorus#646: the Qorus IDE's + pipeline processor, whose required field mappings arrive with its schema). */ + const formTheSchemaMakes = fixOptions(fixedValue, options, undefined, isRendererOnly); + if (Object.keys(formTheSchemaMakes || {}).some((name) => !(name in fixedValue))) { return; } @@ -3097,17 +3116,23 @@ const FormEngineImpl = ({ return; } - // Mark done even when nothing needs attention, so later value edits or an - // in-place schema reload never re-scan or re-focus. Only a remount arms it - // again. + // Looked at: a field here now is not looked at again, whatever it comes to hold. + orderedNames.forEach((name) => scannedFieldsRef.current.add(name)); + const target = firstAttentionRow(arrived); + if (!target) { + // nothing that arrived needs an answer: the scan waits for the fields still to come + return; + } hasAutoFocusedRef.current = true; - openFirstAttentionRow(firstAttentionRow()); + openFirstAttentionRow(target); }, [ autoFocusFirstRequired, expandFirstRequired, compact, options, + schemaInForce, availableOptions, + isRendererOnly, readOnly, dependencyLockedNames, getOptionBucket, From b222dc9b9b09d6f8b97ad908ca915d6b2a9259f3 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 04:22:53 +0200 Subject: [PATCH 157/262] fix(color): the problem under the inputs is set apart by its own wrapper, not a rule on Reqore's message (qorus#646) 98c14893 spaced the colour field's problem from its inputs with a rule on `.reqore-message` inside the field's block: a consumer styling a Reqore component through its class, which FRONTEND.md does not allow. The wrapper the problem is said in is the field's own, so it takes the 6px itself, and only while it says something. Nothing moves: the wrapper holds only the message. Found auditing #137's whole diff for the review round. --- src/components/form/fields/color/Color.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/form/fields/color/Color.tsx b/src/components/form/fields/color/Color.tsx index 98970402..b1a39750 100644 --- a/src/components/form/fields/color/Color.tsx +++ b/src/components/form/fields/color/Color.tsx @@ -423,10 +423,11 @@ const StyledAreaThumb = styled.div<{ $size: number }>` const StyledInputsBlock = styled.div` display: flex; flex-flow: column; +`; - .reqore-message { - margin-top: 6px; - } +/** Where a value's problem is said, under the inputs: set apart from them only while it says something. */ +const StyledProblem = styled.div<{ $shown: boolean }>` + margin-top: ${({ $shown }) => ($shown ? 6 : 0)}px; `; const StyledInputs = styled.div` @@ -900,13 +901,18 @@ export const ColorFormField = ({ {!disableAlpha && inputFor('a', words.opacity, 1, coarse ? 64 : 56)} {/* always there, so what appears in it is read out */} -
+ {problem && ( {renderMarkdown({ value: problem.message, compact: true })} )} -
+
Date: Sat, 17 Oct 2026 05:27:14 +0200 Subject: [PATCH 158/262] fix(expressions): the Text / Visual toggle is as wide as its words, and says them in the host's language (qorus#646) The toggle was a fluid control group, and the field's column stretches what it holds, so on any screen it ran across the whole form (1160px in a 580px story's field). David's width rule is that a control is as wide as what it says on any screen wider than a phone. The toggle is no longer fluid and is not stretched. Its words were English only. ExpressionField takes `modeLabels` (text, visual, and why Text is unavailable), with English defaults. TemplateField hands it on as `expressionModeLabels`. Stories: ExpressionField > Default now checks that the toggle is under half the field's width (it fails before: 1160 vs 580). ExpressionField > Mode Toggle In The Host's Words is new. --- .../expressions/ExpressionField.stories.tsx | 37 +++++++++++++++- .../form/expressions/ExpressionField.tsx | 43 ++++++++++++++++--- .../form/fields/template/TemplateField.tsx | 10 ++++- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/components/form/expressions/ExpressionField.stories.tsx b/src/components/form/expressions/ExpressionField.stories.tsx index c23ad458..2d1cdbdb 100644 --- a/src/components/form/expressions/ExpressionField.stories.tsx +++ b/src/components/form/expressions/ExpressionField.stories.tsx @@ -102,7 +102,7 @@ export const Default: Story = { docs: { description: { story: - 'Renders ExpressionField holding a "$local:name == John" expression, opened in its Visual view — the Visual builder shows the Logical Equals operator with its two operands and the Text/Visual mode toggle.', + 'Renders ExpressionField holding a "$local:name == John" expression, opened in its Visual view — the Visual builder shows the Logical Equals operator with its two operands and the Text/Visual mode toggle, as wide as its two buttons.', }, }, }, @@ -115,6 +115,41 @@ export const Default: Story = { timeout: 6000, }); await expect(await canvas.findByText('Logical Equals')).toBeInTheDocument(); + // the toggle is as wide as what its two buttons say, not across the field + const field = canvasElement.querySelector('.expression-field') as HTMLElement; + const toggle = canvasElement.querySelector('.expression-mode-toggle') as HTMLElement; + await expect(toggle.getBoundingClientRect().width).toBeLessThan( + field.getBoundingClientRect().width / 2 + ); + }, +}; + +/** The toggle's words are the host's: a host that translates its screens gives them (`modeLabels`). */ +export const ModeToggleInTheHostsWords: Story = { + args: { + value: SAMPLE, + defaultMode: 'visual', + modeLabels: { text: 'Texte', visual: 'Visuel' }, + }, + parameters: { + docs: { + description: { + story: + 'Renders ExpressionField opened in its Visual view, with its Text / Visual toggle in the words a host gave it (`modeLabels`): "Texte" and "Visuel".', + }, + }, + }, + async play({ canvasElement }) { + const toggle = await waitFor(() => { + const found = canvasElement.querySelector('.expression-mode-toggle'); + expect(found).toBeTruthy(); + return found!; + }); + await expect( + Array.from(toggle.querySelectorAll('.reqore-button')).map((button) => + button.textContent?.trim() + ) + ).toEqual(['Texte', 'Visuel']); }, }; diff --git a/src/components/form/expressions/ExpressionField.tsx b/src/components/form/expressions/ExpressionField.tsx index 27433531..8e5dfab5 100644 --- a/src/components/form/expressions/ExpressionField.tsx +++ b/src/components/form/expressions/ExpressionField.tsx @@ -48,6 +48,27 @@ const PARSE_DEBOUNCE_MS = 300; */ const PREVIEW_DEBOUNCE_MS = 400; +/** + * A control in the field's column at its own width: the column stretches what it holds across it. The Text / + * Visual toggle is as wide as what the two say, not across the form: a control is its own width on any screen + * wider than a phone's (qorus#646, David's width rule), so it is not fluid either. + */ +const OWN_WIDTH_STYLE: React.CSSProperties = { alignSelf: 'flex-start' }; + +/** The words of the field's Text / Visual toggle, for a host that translates them. */ +export interface IExpressionModeLabels { + text: string; + visual: string; + /** The Text button's hover when there is no language server to write the expression with. */ + textUnavailable: string; +} + +export const DEFAULT_EXPRESSION_MODE_LABELS: IExpressionModeLabels = { + text: 'Text', + visual: 'Visual', + textUnavailable: 'The expression language server is not available', +}; + export interface IExpressionFieldProps { /** The expression value: `{ is_expression:true, value:{ exp, args } }`. */ value?: IExpression; @@ -113,6 +134,8 @@ export interface IExpressionFieldProps { requestedMode?: TExpressionMode; /** The host draws the views as tabs of its own: the field's Visual / Text toggle is not drawn. */ hideModeToggle?: boolean; + /** The words of the Text / Visual toggle, for a host that translates them; English where not given. */ + modeLabels?: Partial; /** No language server: the Text view cannot work, and the field shows the Visual view instead. */ onTextUnavailable?: () => void; /** @@ -271,6 +294,7 @@ export const ExpressionField = memo( heldAsValue, requestedMode, hideModeToggle, + modeLabels, onTextUnavailable, onModeChange, flushRef, @@ -285,6 +309,10 @@ export const ExpressionField = memo( const { renderRich } = useRenderExpression(); const [mode, setMode] = useState(defaultMode); + const modeWords = useMemo( + () => ({ ...DEFAULT_EXPRESSION_MODE_LABELS, ...modeLabels }), + [modeLabels] + ); const onModeChangeRef = useRef(onModeChange); onModeChangeRef.current = onModeChange; useEffect(() => { @@ -776,7 +804,12 @@ export const ExpressionField = memo( data-preview={mode === 'text' ? previewState : undefined} > {hideModeToggle ? null : ( - + {/* Text first, and the view an expression opens in: as the value's tabs read (Value · Expression · Visual), the written form before the built one (qorus#646, David) */} - Text + {modeWords.text} - Visual + {modeWords.visual} )} diff --git a/src/components/form/fields/template/TemplateField.tsx b/src/components/form/fields/template/TemplateField.tsx index a8961956..5998fc42 100644 --- a/src/components/form/fields/template/TemplateField.tsx +++ b/src/components/form/fields/template/TemplateField.tsx @@ -49,7 +49,11 @@ import { useWhyDidYouUpdate } from '../../../../hooks/useWhyDidYouUpdate'; import { ExpressionBuilder, IExpressionBuilderProps } from '../../expressions/builder'; // Direct import — the cycle (TemplateField → ExpressionField → builder → // TemplateField) is render-time only, safe like the other Field cycles. -import { ExpressionField, TExpressionMode } from '../../expressions/ExpressionField'; +import { + ExpressionField, + IExpressionModeLabels, + TExpressionMode, +} from '../../expressions/ExpressionField'; import { IExpression, TExpressionReorder } from '../../expressions/types'; import { useExpressions } from '../../expressions/useExpressions'; import { AutoFormField as Auto, IQorusType as IQorusFormType } from '../auto/AutoFormField'; @@ -212,6 +216,8 @@ export interface ITemplateFieldProps extends Partial< templateBadge?: TTemplateBadge; /** The words of the value tabs, for a host that translates them; English where not given. */ valueTabsLabels?: Partial; + /** The words of an expression's own Text / Visual toggle (no value tabs), for a host that translates them. */ + expressionModeLabels?: Partial; /** * SEAM (reqraft): host-injected per-card actions for the expression editor * this field renders in expression mode (the IDE's AI-assist button). @@ -661,6 +667,7 @@ const TemplateFieldImpl = memo( allowTextExpressions, valueTabs, valueTabsLabels, + expressionModeLabels, savedValues, templateBadge, extraActions, @@ -2255,6 +2262,7 @@ const TemplateFieldImpl = memo( condition={isConditionOption(rest)} extraActions={extraActions} size={rest.size} + modeLabels={expressionModeLabels} /* An expression opens on its Text view, the first of Text · Visual, as the value's tabs open on Expression before Visual (qorus#646, David); without a language server it falls back to Visual by itself. It opened on Visual, on an empty "Select operation", unless the From 260083ad89bb13effe165796f7fe6e0b07725a91 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 05:27:26 +0200 Subject: [PATCH 159/262] fix(expressions): an operand's text is as wide as what it holds through TemplateField's own prop, not rules on Reqore's classes (qorus#646) 824e27b9 and 0b675cdd sized an operand's text field with rules on `.reqore-panel` and `.reqore-textarea`, two of them `!important`. They fought the inline width TemplateField itself writes. FRONTEND.md does not allow either. The /audit of #142 stopped on it. TemplateField now takes `textWidth`. 'field' is the default, as before: 150px, wider for what it holds. 'content' makes the box as wide as what it holds, with no minimum, so its row can shrink it. The editor in the box keeps 150px through the panel's own `contentStyle`. The builder asks for 'content' for an operand's own field only. RichTextFormField now hands on all of a caller's panelProps, not only its style, so the content style reaches the editor's panel. Measured on the seven operand stories, the widths are the same as on 0b675cdd: 170; 181 and 150; 166; 154; the narrow columns 126 with nothing past the row; and 150 / 150. No Reqore change is needed. --- .../expressions/builder/argumentWrapper.tsx | 23 ------------- .../form/expressions/builder/index.tsx | 3 ++ .../form/fields/rich-text/RichText.tsx | 2 +- .../form/fields/template/TemplateField.tsx | 34 +++++++++++++++---- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/components/form/expressions/builder/argumentWrapper.tsx b/src/components/form/expressions/builder/argumentWrapper.tsx index e41ebde8..843030cd 100644 --- a/src/components/form/expressions/builder/argumentWrapper.tsx +++ b/src/components/form/expressions/builder/argumentWrapper.tsx @@ -35,29 +35,6 @@ const StyledOperandFieldRow = styled(ReqoreControlGroup)` min-width: 0; max-width: 100%; } - - /* The operand's own value, written as text: as wide as what it holds, and narrower than that only where - its row has less room. TemplateField draws it 150px wide and never narrower than what it holds - (minWidth 'fit-content'), and a min-width beats a max-width: a reference chip's label takes the font of - the text it sits in (qorus#646), so a long one is wider than a narrow condition's column, and the value - pushed its own ⋮ out of the row. So the box takes its width from what it holds, with no minimum, and - the row shrinks it; the 150px is the editor's own minimum, and where the row is narrower the box cuts - the editor at its edge (a chip's name is still on its hover) while its clear button stays in view. - Only the operand's own field: a field nested in another (an untyped value's) keeps its sizing. - !important: the box carries its width and minimum as inline styles. */ - & > .reqore-control-group > .reqore-panel { - width: auto !important; - min-width: 0 !important; - } - - & - > .reqore-control-group - > .reqore-panel - > .reqore-panel-content - > .reqore-control-wrapper - > .reqore-textarea { - min-width: 150px; - } `; export interface IExpressionBuilderArgumentWrapperProps { diff --git a/src/components/form/expressions/builder/index.tsx b/src/components/form/expressions/builder/index.tsx index e8120a7c..a2afa65e 100644 --- a/src/components/form/expressions/builder/index.tsx +++ b/src/components/form/expressions/builder/index.tsx @@ -1140,6 +1140,8 @@ export const Expression = ({ ; /** The words of an expression's own Text / Visual toggle (no value tabs), for a host that translates them. */ expressionModeLabels?: Partial; + /** + * How wide the value is when it is written as text: + * - `field` (the default): 150px, as a free text field is, wider for what it holds, and narrower only + * where its row has less room; + * - `content`: as wide as what it holds, from 150px, and narrower than that only where its row has less + * room, where the editor is cut at the field's edge (a chip's name is still on its hover). An + * expression's operand is drawn so: a reference chip takes the font of the text it sits in, so a long + * one is wider than a narrow condition's column, and a field never narrower than what it holds pushed + * its own ⋮ out of the row (qorus#646). + */ + textWidth?: 'field' | 'content'; /** * SEAM (reqraft): host-injected per-card actions for the expression editor * this field renders in expression mode (the IDE's AI-assist button). @@ -237,6 +248,16 @@ export interface ITemplateFieldProps extends Partial< default_value?: unknown; } +/** The box a value written as text is drawn in, by `textWidth` (see it). */ +const FIELD_WIDTH_BOX: React.CSSProperties = { + width: '150px', + maxWidth: '100%', + minWidth: 'fit-content', +}; +const CONTENT_WIDTH_BOX: React.CSSProperties = { width: 'auto', maxWidth: '100%', minWidth: 0 }; +/** The editor in a `content` box: never narrower than a free text field. */ +const CONTENT_WIDTH_EDITOR: React.CSSProperties = { minWidth: '150px' }; + export const ComponentMap = { string: LongStringField, number: Number, @@ -668,6 +689,7 @@ const TemplateFieldImpl = memo( valueTabs, valueTabsLabels, expressionModeLabels, + textWidth = 'field', savedValues, templateBadge, extraActions, @@ -2415,17 +2437,17 @@ const TemplateFieldImpl = memo( width, pushing the ⋮ past an operand's row in a narrow column. */ fixed={false} fluid - /* 150px wide, as a free text field is, wider for what it holds, and narrower only where its - row has less room - an operand in a narrow column - as a number's input is: a fixed - 150px minimum held it at full width there. */ + /* As wide as `textWidth` says. A field: 150px, as a free text field is, wider for what it + holds, and narrower only where its row has less room, as a number's input is (a fixed 150px + minimum held it at full width in a narrow column). Its content: the box takes its width from + what it holds, with no minimum, and the editor in it keeps 150px. */ panelProps={{ ...(rest as { panelProps?: object }).panelProps, style: { ...(rest as { panelProps?: { style?: object } }).panelProps?.style, - width: '150px', - maxWidth: '100%', - minWidth: 'fit-content', + ...(textWidth === 'content' ? CONTENT_WIDTH_BOX : FIELD_WIDTH_BOX), }, + ...(textWidth === 'content' ? { contentStyle: CONTENT_WIDTH_EDITOR } : {}), }} aria-label={fieldAriaLabel} /> From e31bae1a14e8e0b930f1722c51ea3e607db110e4 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 05:27:26 +0200 Subject: [PATCH 160/262] refactor(template): one reference chip for every view, its label in the font of its text (qorus#646) The reference chip used to be written out in four places: the read summary, the read-only chip and rich text's two kinds of chip. It is now one constant, REFERENCE_CHIP_PROPS: a `tiny` chip whose label takes the font of the text it sits in (`effect.textSize: 'inherit'`, the Reqore prop, where it was an inline style). Written out in each view, the views had drifted apart before, and a chip jumped as a value moved between them. The DPQL editor's chips use it too. They were the one view still drawn at the editor's own size (13px next to 14px text, in the IDE's flagged story). The catalog is written out once a render (`namingKey`), not once per memo that is keyed on it. Tests: - templateChipSizeHarmonized measures the label as drawn: 17px text gives a 17px label, and 10px without the inherited font. The editors' chips are checked to be the same chip. - dpqlEditor/tags expects the chip with no name. --- __tests__/dpqlEditor/tags.test.ts | 4 +- __tests__/templateChipSizeHarmonized.test.tsx | 58 +++++++++++++------ src/components/dpqlEditor/dpqlTags.ts | 4 ++ .../form/fields/rich-text/RichText.tsx | 18 +++--- .../fields/template/ReadOnlyTemplateTag.tsx | 3 +- .../form/fields/template/TemplateText.tsx | 4 +- src/helpers/templates.ts | 12 ++++ 7 files changed, 72 insertions(+), 31 deletions(-) diff --git a/__tests__/dpqlEditor/tags.test.ts b/__tests__/dpqlEditor/tags.test.ts index 63d9d20e..1ff605ed 100644 --- a/__tests__/dpqlEditor/tags.test.ts +++ b/__tests__/dpqlEditor/tags.test.ts @@ -8,6 +8,7 @@ // back to the `$data:{dc_ai_reply.choices[0].message.content}` path. import { describe, expect, it } from 'vitest'; import { makeDpqlTagRenderer } from '../../src/components/dpqlEditor/dpqlTags'; +import { REFERENCE_CHIP_PROPS } from '../../src/helpers/templates'; import type { ISlateElement } from '../../src/components/smartEditor/types'; const TEMPLATES = { @@ -59,6 +60,7 @@ describe('makeDpqlTagRenderer template references', () => { tag('$data:{dc_ai_reply.choices[0].message.content}') ); - expect(props).toEqual({ icon: 'ExchangeDollarLine', intent: 'info' }); + // the reference chip every view draws, and nothing that names it + expect(props).toEqual({ ...REFERENCE_CHIP_PROPS, icon: 'ExchangeDollarLine', intent: 'info' }); }); }); diff --git a/__tests__/templateChipSizeHarmonized.test.tsx b/__tests__/templateChipSizeHarmonized.test.tsx index baeb494e..9a4e8a89 100644 --- a/__tests__/templateChipSizeHarmonized.test.tsx +++ b/__tests__/templateChipSizeHarmonized.test.tsx @@ -1,11 +1,13 @@ // Copyright 2026 Qore Technologies, s.r.o. // A template reference is drawn as the same chip wherever it is shown: the read summary (TemplateText), the -// read-only chip (ReadOnlyTemplateTag) and the expression editor (RichText) (qorus#646). +// read-only chip (ReadOnlyTemplateTag), the rich text editor (RichText) and the expression editor's DPQL chips +// (qorus#646). // -// The three drew the chip at different sizes — `size='tiny'` with the prose's own font, the caller's size, -// and the editor's default size — so switching a value between its Visual and Expression views made the chip -// jump. They now all draw a `tiny` chip whose label takes the font of whatever it sits in (`fontSize: -// 'inherit'`), so a reference is the same size in every view. +// They drew the chip at different sizes — `size='tiny'` with the prose's own font, the caller's size, and the +// editor's default size — so switching a value between its Visual and Expression views made the chip jump. They +// now all draw one chip (`REFERENCE_CHIP_PROPS`): a `tiny` box whose label takes the font of whatever it sits in. +// What is drawn is measured here where jsdom can draw it; the editors' chips are measured in the browser (the +// ExpressionField stories). import { ReqoreUIProvider } from '@qoretechnologies/reqore'; import { render } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -23,14 +25,21 @@ vi.mock('@qoretechnologies/reqore/dist/components/RichTextEditor', async () => { }; }); +import { makeDpqlTagRenderer } from '../src/components/dpqlEditor/dpqlTags'; import { RichTextFormField } from '../src/components/form/fields/rich-text/RichText'; import { ReadOnlyTemplateTag } from '../src/components/form/fields/template/ReadOnlyTemplateTag'; import { TemplateText } from '../src/components/form/fields/template/TemplateText'; +import { REFERENCE_CHIP_PROPS } from '../src/helpers/templates'; const TEMPLATES = { items: [{ label: 'Customer Name', value: '$local:name' }] } as never; -const chipFontSize = (container: HTMLElement): string | undefined => - container.querySelector('.reqore-tag')?.style.fontSize; +/** The size of the text the chip sits in: not one of a tag's own. */ +const HOST_FONT = '17px'; + +/** The font size a chip's label is drawn at. */ +const chipLabelFontSize = (container: HTMLElement): string => + getComputedStyle(container.querySelector('.reqore-tag .reqore-tag-content')!) + .fontSize; beforeEach(() => { editor.props = undefined; @@ -40,25 +49,40 @@ describe('the same template reference chip in every view', () => { it('the read summary chip takes the font it sits in', () => { const { container } = render( - +
+ +
); - expect(chipFontSize(container)).toBe('inherit'); + expect(chipLabelFontSize(container)).toBe(HOST_FONT); }); it('the read-only chip takes the font it sits in', () => { const { container } = render( - +
+ +
); - expect(chipFontSize(container)).toBe('inherit'); + expect(chipLabelFontSize(container)).toBe(HOST_FONT); + }); + + it('the expression editor chips are the same chip', () => { + const renderTag = makeDpqlTagRenderer({}); + + expect(renderTag({ type: 'tag', value: '@create', children: [{ text: '' }] })).toMatchObject( + REFERENCE_CHIP_PROPS + ); + expect( + renderTag({ type: 'tag', value: '$local:name', children: [{ text: '' }] }) + ).toMatchObject(REFERENCE_CHIP_PROPS); }); - it('the expression editor chip is a tiny chip that takes the font it sits in', () => { + it('the rich text editor chip is the same chip', () => { render( { /> ); const [paragraph] = editor.props?.value; - const props = editor.props?.getTagProps(paragraph.children[1]); - expect(props.size).toBe('tiny'); - expect(props.style?.fontSize).toBe('inherit'); + expect(editor.props?.getTagProps(paragraph.children[1])).toMatchObject(REFERENCE_CHIP_PROPS); }); }); diff --git a/src/components/dpqlEditor/dpqlTags.ts b/src/components/dpqlEditor/dpqlTags.ts index 87670711..be18f924 100644 --- a/src/components/dpqlEditor/dpqlTags.ts +++ b/src/components/dpqlEditor/dpqlTags.ts @@ -8,6 +8,7 @@ import { descriptionTooltip, IDescriptionTooltipOptions } from '../../helpers/de import { describeTemplateReference, getTemplateTagStyle, + REFERENCE_CHIP_PROPS, templateTooltip, TTemplateMeta, } from '../../helpers/templates'; @@ -101,6 +102,8 @@ export function makeDpqlTagRenderer( : undefined; return { + // the same chip every view draws a reference as: the size of the text it sits in + ...REFERENCE_CHIP_PROPS, icon: 'Database2Line', color: FIELD_REF_COLOR.fg as `#${string}`, tooltip, @@ -111,6 +114,7 @@ export function makeDpqlTagRenderer( if (tagValue.startsWith('$')) { const named = options.templates && describeTemplateReference(options.templates, tagValue); return { + ...REFERENCE_CHIP_PROPS, icon: 'ExchangeDollarLine', ...(named?.item ? { diff --git a/src/components/form/fields/rich-text/RichText.tsx b/src/components/form/fields/rich-text/RichText.tsx index b7bd9560..4ad315e0 100644 --- a/src/components/form/fields/rich-text/RichText.tsx +++ b/src/components/form/fields/rich-text/RichText.tsx @@ -34,6 +34,7 @@ import { import { describeTemplateReference, getTemplateTagStyle, + REFERENCE_CHIP_PROPS, templateTooltip, TTemplateMeta, } from '../../../../helpers/templates'; @@ -186,6 +187,8 @@ export const RichTextFormField = memo(({ const isText = valueFormat === 'text'; /** What chips are named from: the whole catalogue, not only what is offered (see the prop). */ const naming = namingTemplates ?? templates; + // the catalogue, as what follows it is keyed on: written out once a render - it is the whole catalogue + const namingKey = JSON.stringify(naming); /* The Slate editor behind Reqore's: the two choice-chip actions below rewrite the document in place, which the editor's props cannot, and the completion list reads and edits the text through it. */ const editorRef = useRef(null); @@ -309,7 +312,7 @@ export const RichTextFormField = memo(({ current : rebuilt ); - }, [JSON.stringify(naming), String(templateToken)]); + }, [namingKey, String(templateToken)]); /** What the host will hold once the emits in flight come back: what an edit is a change from. */ const settled = () => emitted.settled(isText ? String(value ?? '') : value); @@ -386,7 +389,7 @@ export const RichTextFormField = memo(({ // string, and opens on a tap where there is no hover. const descriptions = useDescriptionTooltips(); - const descriptionByValue = useMemo(() => getChipTooltipDescriptions(naming), [JSON.stringify(naming)]); + const descriptionByValue = useMemo(() => getChipTooltipDescriptions(naming), [namingKey]); /* Opens a choice chip into its value as text and puts the cursor there. A click on the chip — a non-editable node — need not have given the @@ -460,10 +463,8 @@ export const RichTextFormField = memo(({ leftIconProps: { image: metadata?.image }, label: templateChipLabel(naming, tagValue), tooltip: templateTooltip(naming, tagValue, descriptions), - // The same chip the read summary (`TemplateText`) and the read-only tag draw: a `tiny` box whose - // label takes the font of the text it sits in, so a reference is the same size in every view. - size: 'tiny', - style: { fontSize: 'inherit' }, + // The same chip the read summary (`TemplateText`) and the read-only tag draw. + ...REFERENCE_CHIP_PROPS, ...getTemplateTagStyle(metadata), }; } @@ -484,9 +485,8 @@ export const RichTextFormField = memo(({ /* Drawn, as the picker row draws the same string (`descriptionTooltip`): left raw, it printed the punctuation the row does not. */ tooltip: descriptionTooltip(descriptionByValue[tagValue], descriptions), - // The same chip the read summary and the read-only tag draw — see the `isText` branch above. - size: 'tiny', - style: { fontSize: 'inherit' }, + // The same chip the read summary and the read-only tag draw. + ...REFERENCE_CHIP_PROPS, ...getTemplateTagStyle(tag.metadata), }; } diff --git a/src/components/form/fields/template/ReadOnlyTemplateTag.tsx b/src/components/form/fields/template/ReadOnlyTemplateTag.tsx index 9a4ca411..a7ee4712 100644 --- a/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +++ b/src/components/form/fields/template/ReadOnlyTemplateTag.tsx @@ -4,6 +4,7 @@ import { memo } from 'react'; import { getTemplateTagStyle, describeTemplateReference, + REFERENCE_CHIP_PROPS, templateTooltip, TTemplateMeta, } from '../../../../helpers/templates'; @@ -41,7 +42,7 @@ export const ReadOnlyTemplateTag = memo( leftIconProps={{ image: metadata?.image }} label={label} tooltip={templateTooltip(templates, value, descriptions)} - style={{ fontSize: 'inherit' }} + {...REFERENCE_CHIP_PROPS} {...getTemplateTagStyle(metadata)} /> diff --git a/src/components/form/fields/template/TemplateText.tsx b/src/components/form/fields/template/TemplateText.tsx index 18948eb4..2d8c56ce 100644 --- a/src/components/form/fields/template/TemplateText.tsx +++ b/src/components/form/fields/template/TemplateText.tsx @@ -6,6 +6,7 @@ import { IRichtextSegment } from '../../../../helpers/common'; import { findTemplate, getTemplateTagStyle, + REFERENCE_CHIP_PROPS, templateTooltip, TTemplateMeta, } from '../../../../helpers/templates'; @@ -83,13 +84,12 @@ const TemplateTextBody = ({ segments, templates, full, className }: ITemplateTex export type TTemplateMeta = { image?: string; builtIn?: boolean; isEventTrigger?: boolean }; +/** + * A reference chip as every view draws it - the read summary (`TemplateText`), the read-only chip, the rich + * text editor and the expression editor: a `tiny` box whose label takes the font of the text it sits in, so a + * reference is the same size wherever it is shown, at whatever size its text is (qorus#646). One object for all + * of them: written out in each, the views drifted apart, and a chip jumped as a value moved between them. + */ +export const REFERENCE_CHIP_PROPS: Pick = Object.freeze({ + size: 'tiny', + effect: Object.freeze({ textSize: 'inherit' }), +}); + export const getTemplateTagStyle = ( meta?: TTemplateMeta ): { intent: 'success' | 'info' | 'custom1' } => { From ccb9fc222fbce2d34828518f66866bbe9c1f2d32 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 05:27:40 +0200 Subject: [PATCH 161/262] fix(dpql): an expression's text is drawn at the field's size, as its Value tab draws the same value (qorus#646) The Expression tab drew its DPQL editor at the normal size in any form. The Value tab draws the same value at the field's size. On a small form the two tabs drew one reference at two sizes: "@pos" was smaller on Value than on Expression (David, the Qorus IDE's sheet contract review). The SmartEditor and the DPQL editor take `size`, and ExpressionField hands them the field's. The template button beside the text takes the same size, so the two stay as tall as each other. With e31bae1a's chip, a reference is now the same chip on both tabs. Story: FormEngine > Reference Is The Same Chip On Value And Expression (small form). The chip's label is the size of its text on both tabs, and the chip is as tall on both. It fails on 0b675cdd: a 13px label beside 14px text. --- src/components/dpqlEditor/DpqlEditor.tsx | 2 + src/components/dpqlEditor/types.ts | 7 +++ .../form/engine/FormEngine.stories.tsx | 51 ++++++++++++++++++- .../form/expressions/ExpressionField.tsx | 8 +-- src/components/smartEditor/SmartEditor.tsx | 2 + src/components/smartEditor/types.ts | 8 +++ 6 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/components/dpqlEditor/DpqlEditor.tsx b/src/components/dpqlEditor/DpqlEditor.tsx index 6820c90a..9a86b861 100644 --- a/src/components/dpqlEditor/DpqlEditor.tsx +++ b/src/components/dpqlEditor/DpqlEditor.tsx @@ -42,6 +42,7 @@ export const DpqlEditor = forwardRef( options, actionCode, height, + size, readOnly = false, showDiagnostics = true, enableHover = true, @@ -210,6 +211,7 @@ export const DpqlEditor = forwardRef( converter={converter} completionInserter={dpqlCompletionInserter} height={height} + size={size} readOnly={readOnly} showDiagnostics={showDiagnostics} enableHover={enableHover} diff --git a/src/components/dpqlEditor/types.ts b/src/components/dpqlEditor/types.ts index e04d6ac1..2c174900 100644 --- a/src/components/dpqlEditor/types.ts +++ b/src/components/dpqlEditor/types.ts @@ -3,6 +3,7 @@ // (`ISlateElement`, `ISlateText`, `TSlateNode`) live in the smartEditor // primitive's `types.ts` — re-export them here for backward compat. import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; +import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes'; export type { ISlateElement, @@ -108,6 +109,12 @@ export interface IDpqlEditorProps { * fields); pass e.g. `'200px'` for a taller multi-line code editor. */ height?: string; + /** + * The size the text is drawn at, as a field of that size draws it - and the chips in it with it. A host + * drawing the same value as another field (an expression's Value and Expression tabs) gives both one size, + * so a reference is the same chip in either (qorus#646). The editor's own when not given. + */ + size?: TSizes; /** Read-only mode. */ readOnly?: boolean; /** diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index 97df4280..7032c38d 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -10365,12 +10365,13 @@ const TEMPLATE_OR_EXPRESSION_VALUE = { }; /** The form, and what each option holds - its value, and whether it is an expression. */ -const TemplateOrExpressionForm = () => { +const TemplateOrExpressionForm = ({ size }: { size?: TSizes } = {}) => { const [value, setValue] = useState(TEMPLATE_OR_EXPRESSION_VALUE); return ( <> { + const chip = editor.querySelector('.reqore-tag') as HTMLElement; + expect(chip, 'a chip in the editor').toBeTruthy(); + return { + label: getComputedStyle(chip.querySelector('.reqore-tag-content') as HTMLElement).fontSize, + text: getComputedStyle(chip.parentElement as HTMLElement).fontSize, + height: chip.getBoundingClientRect().height, + }; +}; + +export const ReferenceIsTheSameChipOnValueAndExpression: Story = { + parameters: { + docs: { + description: { + story: + 'Renders a small form and opens Note, text holding "SUP-" and the field pos, on Value and then on Expression. When either tab shows it, the field is the same chip: its label the size of the text around it, and as tall in both.', + }, + }, + }, + render: () => , + play: async () => { + const row = await editOption('note'); + const onValue = chipLook( + await waitFor(() => { + const el = row.querySelector('.value-tab-text [data-slate-editor]'); + expect(el?.querySelector('.reqore-tag')).toBeTruthy(); + return el as HTMLElement; + }) + ); + await chooseTab(row, 'expression'); + const onExpression = chipLook( + await waitFor(() => { + const el = optionRow('note').querySelector( + '.expression-field [data-slate-editor]' + ); + expect(el?.querySelector('.reqore-tag')).toBeTruthy(); + return el as HTMLElement; + }) + ); + + expect(onValue.label).toBe(onValue.text); + expect(onExpression.label).toBe(onExpression.text); + expect(onExpression.label).toBe(onValue.label); + expect(Math.abs(onExpression.height - onValue.height)).toBeLessThanOrEqual(1); + }, +}; + export const SavedTextWithTemplatesStaysText: Story = { parameters: { docs: { diff --git a/src/components/form/expressions/ExpressionField.tsx b/src/components/form/expressions/ExpressionField.tsx index 8e5dfab5..76c27ae2 100644 --- a/src/components/form/expressions/ExpressionField.tsx +++ b/src/components/form/expressions/ExpressionField.tsx @@ -13,6 +13,7 @@ import { ReqoreVerticalSpacer, } from '@qoretechnologies/reqore'; import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; +import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes'; import { memo, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DpqlEditor, IDpqlEditorRef, IDpqlParseResult, useDpqlSession } from '../../dpqlEditor'; import { TemplateBrowser } from '../fields/template/TemplateBrowser'; @@ -859,6 +860,8 @@ export const ExpressionField = memo( // the field's own templates name the chips as its catalogue names them templates={localTemplates} readOnly={readOnly} + // the field's size, as its Value tab draws the same value: a reference is the same chip in both + size={size as TSizes | undefined} // no floor of its own: one line as tall as the text it shows, growing with its lines - a // 48px floor left an empty strip under the text, which the picker beside it could not match onUnavailable={handleTextUnavailable} @@ -872,9 +875,8 @@ export const ExpressionField = memo( icon='MoneyDollarCircleLine' aria-label='Insert a template' tooltip='Insert a template' - // the size of the text it inserts into - the DPQL editor is drawn at the normal size in - // any form - so the two are as tall as each other - size='normal' + // the size of the text it inserts into, so the two are as tall as each other + size={(size as TSizes | undefined) ?? 'normal'} fixed templates={localTemplates} focusFilter diff --git a/src/components/smartEditor/SmartEditor.tsx b/src/components/smartEditor/SmartEditor.tsx index 38188761..762ce7ff 100644 --- a/src/components/smartEditor/SmartEditor.tsx +++ b/src/components/smartEditor/SmartEditor.tsx @@ -229,6 +229,7 @@ export const SmartEditor = forwardRef Date: Sat, 17 Oct 2026 05:27:40 +0200 Subject: [PATCH 162/262] fix(smart-editor): a reference typed out in full becomes its chip once it has been typed (qorus#646) A reference became a chip only when it was picked from the completion list, or when the editor built its document from a value it was handed. One typed out in full stayed text, because a document being edited is left as it is. The Qorus IDE's test case typed "@create + 1" as its expected value, and "@create" was never a chip, not even after the editor was left (David). The editor now redraws a paragraph with its typed references as chips at two points: - once a character that ends the reference is typed after it, with the caret staying where it was in the text; - when the editor is left. A reference still being typed is left as text. Chipped mid-word, the rest of the word could not be typed into it. The converter says which characters end a reference (`endsReference`). DPQL ends one at anything but a name character, `.` (a sub-field), `:` or `{` (a template's path). A converter that does not say draws typed references as chips only when the editor is left. Tests: typedReferencesBecomeChips, 8 cases: a space or an operator after it, still being typed, a dot after it, left, a template reference, already chips, no reference. Story: ExpressionField > Text Mode Reference Typed Out Is A Chip types "@status + 2" and checks @status is a chip. It fails on 0b675cdd. --- __tests__/typedReferencesBecomeChips.test.ts | 61 ++++++++++++ src/components/dpqlEditor/dpqlHelpers.ts | 9 ++ .../expressions/ExpressionField.stories.tsx | 36 +++++++ src/components/smartEditor/SmartEditor.tsx | 56 ++++++++++- src/components/smartEditor/typedReferences.ts | 96 +++++++++++++++++++ src/components/smartEditor/types.ts | 7 ++ 6 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 __tests__/typedReferencesBecomeChips.test.ts create mode 100644 src/components/smartEditor/typedReferences.ts diff --git a/__tests__/typedReferencesBecomeChips.test.ts b/__tests__/typedReferencesBecomeChips.test.ts new file mode 100644 index 00000000..961f07b9 --- /dev/null +++ b/__tests__/typedReferencesBecomeChips.test.ts @@ -0,0 +1,61 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A reference typed out in full is drawn as its chip once it has been typed, as one picked from the completion list +// is (qorus#646): the Qorus IDE's test case typed `@create + 1` as its expected value, and `@create` stayed text. +import { describe, expect, it } from 'vitest'; +import { dpqlSlateConverter } from '../src/components/dpqlEditor/dpqlHelpers'; +import { rechipTypedReferences } from '../src/components/smartEditor/typedReferences'; +import { ISlateElement } from '../src/components/smartEditor/types'; + +/** A paragraph as typed: all of it one text node, no chip. */ +const typed = (text: string): ISlateElement => ({ type: 'paragraph', children: [{ text }] }); + +const chipsOf = (children: unknown[] | undefined) => + (children as ISlateElement[] | undefined) + ?.filter((node) => node.type === 'tag') + .map((node) => node.value); + +describe('a reference typed out in full', () => { + it('is drawn as its chip once a space follows it, the caret where it was', () => { + const rechipped = rechipTypedReferences(dpqlSlateConverter, typed('@create + 1'), 0, 11); + + expect(chipsOf(rechipped?.children)).toEqual(['@create']); + // the caret stays after "+ 1", in the text after the chip + expect(rechipped?.caret).toEqual({ path: [0, 2], offset: 4 }); + }); + + it('is drawn as its chip once an operator follows it', () => { + expect( + chipsOf(rechipTypedReferences(dpqlSlateConverter, typed('@create+'), 0, 8)?.children) + ).toEqual(['@create']); + }); + + it('is left as text while it is still being typed', () => { + expect(rechipTypedReferences(dpqlSlateConverter, typed('@crea'), 0, 5)).toBeUndefined(); + }); + + it('is left as text when a dot follows it: a sub-field may come next', () => { + expect(rechipTypedReferences(dpqlSlateConverter, typed('@create.'), 0, 8)).toBeUndefined(); + }); + + it('is drawn as its chip when the editor is left, though nothing followed it', () => { + expect( + chipsOf(rechipTypedReferences(dpqlSlateConverter, typed('@create'), 0)?.children) + ).toEqual(['@create']); + }); + + it('a template reference too', () => { + expect( + chipsOf(rechipTypedReferences(dpqlSlateConverter, typed('$local:name == 1'), 0, 16)?.children) + ).toEqual(['$local:name']); + }); + + it('a paragraph whose chips are all drawn already is left as it is', () => { + const [drawn] = dpqlSlateConverter.toSlateNodes('@create + 1'); + + expect(rechipTypedReferences(dpqlSlateConverter, drawn, 0, 11)).toBeUndefined(); + }); + + it('text with no reference in it is left as it is', () => { + expect(rechipTypedReferences(dpqlSlateConverter, typed('1 + 2 '), 0, 6)).toBeUndefined(); + }); +}); diff --git a/src/components/dpqlEditor/dpqlHelpers.ts b/src/components/dpqlEditor/dpqlHelpers.ts index 69cba154..e60428f7 100644 --- a/src/components/dpqlEditor/dpqlHelpers.ts +++ b/src/components/dpqlEditor/dpqlHelpers.ts @@ -237,11 +237,20 @@ export function getTagLabel(value: string): string { return value; } +/** + * Whether a character ends the DPQL reference it follows: anything that cannot go on to make a field name or a + * template path longer - not a letter, a mark, a digit or `_`, nor a `.` (a sub-field) or a `:` / `{` (a + * template's path). + */ +export const endsDpqlReference = (char: string): boolean => + !!char && !/^[\p{L}\p{M}\p{N}_.:{]$/u.test(char); + /** DPQL converter — handed to `SmartEditor` via the `converter` prop. */ export const dpqlSlateConverter: ISlateConverter = { toSlateNodes: plainTextToSlate, fromSlateNodes: slateToPlainText, selectionToOffset: slateSelectionToOffset, + endsReference: endsDpqlReference, }; /** diff --git a/src/components/form/expressions/ExpressionField.stories.tsx b/src/components/form/expressions/ExpressionField.stories.tsx index 2d1cdbdb..11c0f36b 100644 --- a/src/components/form/expressions/ExpressionField.stories.tsx +++ b/src/components/form/expressions/ExpressionField.stories.tsx @@ -894,6 +894,42 @@ export const TextMode: Story = { }, }; +export const TextModeReferenceTypedOutIsAChip: Story = { + args: { + value: { is_expression: true, value: { args: [] } }, + defaultMode: 'text', + }, + parameters: { + docs: { + description: { + story: + 'Renders ExpressionField in Text (DPQL) mode and types "@status + 2" without picking from the list. When the space after it is typed, @status is drawn as its chip, as one picked from the list is; the rest is typed after it.', + }, + }, + }, + async play({ canvasElement }) { + const editable = (await waitFor( + () => { + const el = canvasElement.querySelector('[contenteditable="true"]'); + if (!el) throw new Error('editor not ready'); + return el as HTMLElement; + }, + { timeout: 6000 } + )) as HTMLElement; + + await userEvent.click(editable); + await userEvent.type(editable, '@status + 2'); + + await waitFor(() => { + const chips = [...editable.querySelectorAll('.reqore-tag')].map((chip) => chip.textContent); + expect(chips).toEqual([expect.stringContaining('status')]); + }); + // what was typed after it is in the text, in the order it was typed + expect(editable.textContent?.replace(/\uFEFF/g, '')).toMatch(/status\s*\+ 2$/); + await waitForLspIdle(canvasElement); + }, +}; + /** A comparison built in the Visual view and left unfinished, seen in the Text view. */ export const TextModeOfAnUnfinishedExpression: Story = { args: { diff --git a/src/components/smartEditor/SmartEditor.tsx b/src/components/smartEditor/SmartEditor.tsx index 762ce7ff..3b6f3760 100644 --- a/src/components/smartEditor/SmartEditor.tsx +++ b/src/components/smartEditor/SmartEditor.tsx @@ -33,7 +33,8 @@ import { defaultSlateConverter, expandSnippet, lspPositionToOffset } from './hel import { TEMPLATE_BROWSE_LIST_PROPS } from '../form/fields/rich-text/useTemplateTags'; import { CompletionMenu } from './CompletionMenu'; import { MarkdownDoc } from './MarkdownDoc'; -import { ISlateElement, ISmartEditorProps, TCompletionInserter } from './types'; +import { ISlateElement, ISmartEditorProps, TCompletionInserter, TSlateNode } from './types'; +import { IRechippedParagraph, rechipTypedReferences } from './typedReferences'; import { ICompletionDropdownItem, useLspAutocomplete } from './useLspAutocomplete'; import { severityToIntent, useLspDiagnosticDecorations } from './useLspDiagnosticDecorations'; import { useLspHover } from './useLspHover'; @@ -428,6 +429,38 @@ export const SmartEditor = forwardRef { + Editor.withoutNormalizing(editor, () => { + const paragraph = editor.children[index] as unknown as ISlateElement; + for (let child = paragraph.children.length - 1; child >= 0; child -= 1) { + Transforms.removeNodes(editor, { at: [index, child] }); + } + Transforms.insertNodes(editor, rechipped.children as TSlateNode[] as never[], { + at: [index, 0], + }); + }); + if (rechipped.caret) { + Transforms.select(editor, rechipped.caret); + } + }; + + // the editor has been left: every reference typed in it is drawn as its chip + const handleBlur = useCallback(() => { + const editor = editorRef.current as unknown as Editor | null; + if (editor && !readOnly) { + (editor.children as unknown as ISlateElement[]).forEach((paragraph, index) => { + const rechipped = rechipTypedReferences(converter, paragraph, index); + if (rechipped) { + drawTypedReferences(editor, index, rechipped); + } + }); + } + onBlur?.(); + }, [converter, readOnly, onBlur]); + const handleSlateChange = useCallback( (newNodes: ISlateElement[]) => { const plainText = converter.fromSlateNodes(newNodes); @@ -449,6 +482,25 @@ export const SmartEditor = forwardRef (node as ISlateElement).type === 'tag'; + +/** How many characters of plain text `node` stands for, as the converter writes it. */ +const plainLength = (converter: ISlateConverter, node: TSlateNode): number => + isTag(node) ? + converter.fromSlateNodes([{ type: 'paragraph', children: [{ text: '' }, node, { text: '' }] }]) + .length + : (node as ISlateText).text.length; + +/** Where in `paragraph`'s plain text each of its chips is: `[start, end)`. */ +const chipRanges = (converter: ISlateConverter, paragraph: ISlateElement): [number, number][] => { + const ranges: [number, number][] = []; + let at = 0; + paragraph.children.forEach((child) => { + const length = plainLength(converter, child); + if (isTag(child)) { + ranges.push([at, at + length]); + } + at += length; + }); + return ranges; +}; + +/** The Slate point at plain-text `offset` in `paragraph` (at index `index`): in a text node, never in a chip. */ +const pointAt = ( + converter: ISlateConverter, + paragraph: ISlateElement, + index: number, + offset: number +): { path: number[]; offset: number } => { + let at = 0; + for (let child = 0; child < paragraph.children.length; child += 1) { + const node = paragraph.children[child]; + const length = plainLength(converter, node); + if (!isTag(node) && offset <= at + length) { + return { path: [index, child], offset: offset - at }; + } + at += length; + } + const last = paragraph.children.length - 1; + return { path: [index, last], offset: plainLength(converter, paragraph.children[last]) }; +}; + +export interface IRechippedParagraph { + /** The paragraph's children, with the references typed in it drawn as chips. */ + children: TSlateNode[]; + /** Where the caret goes in them: the same place in the text. */ + caret?: { path: number[]; offset: number }; +} + +/** + * The paragraph at `index`, its typed references drawn as chips - or `undefined` when there is none to draw. + * + * A reference is typed out in full when the converter reads one where the paragraph has plain text. With a + * `caret` (the plain-text offset of the caret in the paragraph), only a reference the caret has left is drawn: + * the converter says the character typed right after it ends it (`endsReference`) - a space or an operator, not + * a `.` that goes on to a sub-field. One still being typed is left as text; drawn as a chip mid-word, the rest of + * the word could not be typed into it. Without a caret (the editor has been left) every one is drawn. + */ +export const rechipTypedReferences = ( + converter: ISlateConverter, + paragraph: ISlateElement, + index: number, + caret?: number +): IRechippedParagraph | undefined => { + const text = converter.fromSlateNodes([paragraph]); + const [rebuilt] = converter.toSlateNodes(text); + if (!rebuilt) { + return undefined; + } + const had = chipRanges(converter, paragraph); + const typed = chipRanges(converter, rebuilt).filter( + ([start, end]) => !had.some(([from, to]) => from === start && to === end) + ); + if (!typed.length || typed.length + had.length !== chipRanges(converter, rebuilt).length) { + // nothing typed out, or a reading the paragraph does not share (a chip it holds is not one the text makes) + return undefined; + } + if (caret !== undefined) { + const ended = typed.every( + ([, end]) => end < caret && !!converter.endsReference?.(text.charAt(end)) + ); + if (!ended) { + return undefined; + } + } + return { + children: rebuilt.children, + caret: caret === undefined ? undefined : pointAt(converter, rebuilt, index, caret), + }; +}; diff --git a/src/components/smartEditor/types.ts b/src/components/smartEditor/types.ts index 2c03f80a..b0f49ff3 100644 --- a/src/components/smartEditor/types.ts +++ b/src/components/smartEditor/types.ts @@ -50,6 +50,13 @@ export interface ISlateConverter { anchorPath: number[], anchorOffset: number ) => number; + /** + * Whether `char`, typed right after a reference, ends it: it cannot go on to make the reference longer (a + * space or an operator, not a `.` before a sub-field). A reference typed out in full is drawn as its chip once + * such a character follows it, as one picked from the completion list is. Without it, a typed reference is + * drawn as its chip only once the editor is left. + */ + endsReference?: (char: string) => boolean; } /** From 639dd1f9f1c49f502367359b6a7c8d1bb81f8c09 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 05:27:40 +0200 Subject: [PATCH 163/262] test(stories): a reference is drawn as its chip when its catalog arrives (qorus#646) e4fc5528's fix (#11) had only a unit test. RichText > Chip Drawn When Its Catalog Arrives renders "@create + 1" before the template catalog has arrived. When it arrives, "@create" is drawn as its "Created order" chip and the text is unchanged. --- .../fields/rich-text/RichText.stories.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/components/form/fields/rich-text/RichText.stories.tsx b/src/components/form/fields/rich-text/RichText.stories.tsx index 63196b91..664d306c 100644 --- a/src/components/form/fields/rich-text/RichText.stories.tsx +++ b/src/components/form/fields/rich-text/RichText.stories.tsx @@ -235,6 +235,68 @@ export const BackspaceOpensTheChoiceChip: Story = { }, }; +/** A catalog that names a reference no grammar reads: `@create` is a reference only because it names it. */ +const ARRIVING_CATALOG = { items: [{ label: 'Created order', value: '@create' }] } as never; +const NO_CATALOG_YET = { items: [] } as never; + +export const ChipDrawnWhenItsCatalogArrives: Story = { + args: { + valueFormat: 'text', + allowTemplates: true, + value: '@create + 1', + }, + parameters: { + docs: { + description: { + story: + 'Renders the RichText field holding "@create + 1" before its template catalog has arrived, so @create is text. When the catalog arrives (it names @create "Created order"), @create is drawn as the "Created order" chip, the text unchanged.', + }, + }, + }, + render(args) { + const [value, setValue] = useState(args.value); + // the catalog is fetched: it arrives after the field's first render + const [templates, setTemplates] = useState(NO_CATALOG_YET); + return ( + <> + { + args.onChange?.(next); + setValue(next); + }} + /> + + + ); + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const editor = await waitFor(() => { + const el = canvasElement.querySelector('[data-slate-editor]'); + expect(el?.textContent).toContain('@create'); + return el as HTMLElement; + }); + // no catalog yet: nothing names @create + expect(editor.querySelector('.reqore-tag')).toBeNull(); + + await userEvent.click(canvas.getByText('Catalog arrives')); + + await waitFor(() => + expect(editor.querySelector('.reqore-tag')?.textContent).toContain('Created order') + ); + expect(editor.textContent).toContain('+ 1'); + }, +}; + export const Empty: Story = { parameters: { docs: { From efb2eb09fee3b8bbe2303aa2f10921beb200c301 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 17 Oct 2026 17:09:07 +0200 Subject: [PATCH 164/262] fix(expressions): an expression that does not parse is the field's draft, not lost; an emptied one empties the field (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From David's round-4 review in the Qorus IDE (item 1a: a test case's Expected Value). **What the author wrote and does not parse was lost on a tab switch.** `@create + 1` (a hash plus a number) does not parse, so nothing was stored, and the field still held 201. The Expression tab's editor goes when the tab is left, and the next visit seeded it from the stored value, so "201" came back in place of what was written. The rule now: - An expression that does not parse is the field's draft, not its value. - The field keeps the value it held, and says so: the Value tab notes that the Expression tab holds text that does not parse (`expressionDraftNotUsed`, for a host's own words). - Each visit to the Expression tab, directly or after the Visual tab, shows the draft as it was written and reads it again, so its error is said again. - The draft goes when it parses, when it is cleared, or when the value is changed elsewhere: the author has moved on from it. ExpressionField reports its draft (`onDraftChange`) and takes it back (`draftText`). TemplateField keeps the draft over the value it was written over. **The Expression tab's × left the value.** Emptying the text emptied the editor only. Nothing was stored for an empty text, so the field kept 201, and the next visit showed it again. On the Value tab the same × empties the field. An emptied expression now empties the field, as the Visual tab's last operand removed does. The × itself is reqore's, and is named "Clear" by reqore f303c5af (#717). Stories, both failing before: - FormEngine > Unparsed Expression Kept Across Tabs: back on Expression the text was "12" instead of "12 +". - FormEngine > Expression Cleared Empties The Value: Quantity stayed 12. --- .../form/engine/FormEngine.stories.tsx | 93 +++++++++++++++++++ .../form/expressions/ExpressionField.tsx | 67 ++++++++++++- .../form/fields/template/TemplateField.tsx | 45 ++++++++- 3 files changed, 201 insertions(+), 4 deletions(-) diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index ece3a9e6..1cafc6cc 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -11086,6 +11086,99 @@ export const ExpressionTypedOnValueAfterTheExpressionTabShowsWhatWasTyped: Story }, }; +/** The Expression tab's text, without the marks a chip leaves in it. */ +const expressionTextOf = (field: string) => + optionRow(field) + .querySelector('.expression-field [data-slate-editor]') + ?.textContent?.replace(/\uFEFF/g, '') + .replace(/\s+/g, ' ') + .trim(); + +export const UnparsedExpressionKeptAcrossTabs: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form, opens Quantity (12) on the Expression tab and writes "12 +", which does not parse. The field keeps 12: the Value tab shows it, and says the Expression tab holds text that does not parse. When the Expression tab is opened again, directly or after the Visual tab, the text is "12 +" as written, not the 12 the field holds (qorus#646).', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + const row = await editOption('quantity'); + await chooseTab(row, 'expression'); + await waitFor(() => expect(expressionTextOf('quantity')).toBe('12')); + const editor = optionRow('quantity').querySelector( + '.expression-field [data-slate-editor]' + ) as HTMLElement; + await userEvent.click(editor); + // a click past the text puts the caret at its end + await userEvent.keyboard(' +'); + await waitFor(() => expect(expressionTextOf('quantity')).toBe('12 +')); + + // the text does not parse: the field still holds 12 + await chooseTab(optionRow('quantity'), 'value'); + expect(heldOption(canvasElement, 'quantity')).toEqual({ type: 'int', value: 12 }); + await waitFor(() => + expect( + optionRow('quantity').querySelector('.value-tab-text [data-slate-editor]')?.textContent + ).toContain('12') + ); + // and says why it is still 12 + expect( + optionRow('quantity').querySelector('.value-tab-expression-draft')?.textContent + ).toContain('does not parse'); + + // the Expression tab gives back what was written there + await chooseTab(optionRow('quantity'), 'expression'); + await waitFor(() => expect(expressionTextOf('quantity')).toBe('12 +')); + + // and so does a round through the Visual tab + await chooseTab(optionRow('quantity'), 'visual'); + await chooseTab(optionRow('quantity'), 'expression'); + await waitFor(() => expect(expressionTextOf('quantity')).toBe('12 +')); + expect(heldOption(canvasElement, 'quantity')).toEqual({ type: 'int', value: 12 }); + }, +}; + +export const ExpressionClearedEmptiesTheValue: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form and opens Quantity (12) on the Expression tab, then clears the text with its ×. Quantity holds nothing, as clearing it on the Value tab leaves it: the Value tab is empty, and so is the Expression tab when it is opened again (qorus#646).', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + const row = await editOption('quantity'); + await chooseTab(row, 'expression'); + await waitFor(() => expect(expressionTextOf('quantity')).toBe('12')); + const clear = await waitFor(() => { + const el = optionRow('quantity').querySelector( + '.expression-field .reqore-clear-input-button' + ); + expect(el, "the text's clear button").toBeTruthy(); + return el as HTMLElement; + }); + await userEvent.click(clear); + + await waitFor(() => expect(heldOption(canvasElement, 'quantity')?.value).toBeUndefined()); + await chooseTab(optionRow('quantity'), 'value'); + await waitFor(() => + expect( + optionRow('quantity') + .querySelector('.value-tab-text [data-slate-editor]') + ?.textContent?.replace(/\uFEFF/g, '') + .trim() + ).toBe('') + ); + await chooseTab(optionRow('quantity'), 'expression'); + await waitFor(() => expect(expressionTextOf('quantity')).toBe('')); + }, +}; + export const ExpressionWrittenOnExpressionShowsWhenTheTabIsBack: Story = { parameters: { docs: { diff --git a/src/components/form/expressions/ExpressionField.tsx b/src/components/form/expressions/ExpressionField.tsx index 76c27ae2..6a318864 100644 --- a/src/components/form/expressions/ExpressionField.tsx +++ b/src/components/form/expressions/ExpressionField.tsx @@ -122,6 +122,19 @@ export interface IExpressionFieldProps { * when "Use Template / Expression" opened it. Nothing is emitted until it is edited. */ initialText?: string; + /** + * What the author wrote in the Text view on an earlier visit that does not parse, given back by a host that + * unmounts the field between visits (its tabs): shown as it was written and read again, its error with it, + * not the value written out. See `onDraftChange`. + */ + draftText?: string; + /** + * The text written in the Text view that the value does not hold yet - typed and not read, or read and not + * parsed - each time it changes; undefined once it parses or is cleared. For a host that unmounts the field + * between visits to keep it, and give it back as `draftText` while the value is still the one it was written + * over (qorus#646: "@create + 1", which did not parse, became the field's 201 when the author came back). + */ + onDraftChange?: (text: string | undefined) => void; /** * The field holds a plain value the text stands for - a template or a literal written in the Text view * (stored as itself, not as an expression). The expression is then gone from `value` but the field is not @@ -292,6 +305,8 @@ export const ExpressionField = memo( recordType, defaultMode = 'text', initialText, + draftText, + onDraftChange, heldAsValue, requestedMode, hideModeToggle, @@ -325,7 +340,18 @@ export const ExpressionField = memo( // Text mode state. `text` is the DPQL string the editor shows; the AST // (`value`) stays the source of truth, kept in sync via parse-on-edit. - const [text, setText] = useState(initialText ?? ''); + const [text, setText] = useState(draftText ?? initialText ?? ''); + /* What the author wrote in the Text view that the value does not hold (see `onDraftChange`): it is this + field's text until it parses or is cleared, so a later visit to the Text view shows it, not the value + written out. */ + const draftRef = useRef(draftText); + const onDraftChangeRef = useRef(onDraftChange); + onDraftChangeRef.current = onDraftChange; + const setDraft = useCallback((next: string | undefined) => { + if (draftRef.current === next) return; + draftRef.current = next; + onDraftChangeRef.current?.(next); + }, []); /** The text the editor was given, for its echo of that text to be told from typing. */ const textRef = useRef(text); textRef.current = text; @@ -607,6 +633,15 @@ export const ExpressionField = memo( Text view (its tabs) and the session coming up all read through here. */ const flushPendingParse = useCallback(async (): Promise => { if (pendingParse.current === null) return undefined; + /* An emptied text is no value, as an emptied Value tab is: the field holds nothing. It stayed what it + was, and came back on the next visit (qorus#646: the text's × left the 201). */ + if (!pendingParse.current.trim()) { + if (parseTimer.current) clearTimeout(parseTimer.current); + pendingParse.current = null; + setDraft(undefined); + if (ast || heldAsValue) onChange({ is_expression: true } as IExpression, true); + return undefined; + } if (!(await whenSessionReady())) return undefined; const pending = pendingParse.current; const parse = dpqlRef.current?.parse; @@ -615,12 +650,27 @@ export const ExpressionField = memo( pendingParse.current = null; const result = await parse(pending, targetType, parseOptions); readTypeCheck(result); + // not parsed: the value keeps what it held, and the text stays the author's (see `onDraftChange`) if (!result?.success || !result.expression) return undefined; + setDraft(undefined); setAstText(pending); // `dpql/parse` returns the field-ready `{ is_expression, value }`. onChange(result.expression as IExpression); return result.expression as IExpression; - }, [onChange, targetType, parseOptions, readTypeCheck, whenSessionReady]); + }, [ + onChange, + targetType, + parseOptions, + readTypeCheck, + whenSessionReady, + setDraft, + ast, + heldAsValue, + ]); + + // the latest reader, for the seeding below, which is not run again for a new one + const flushLatest = useRef(flushPendingParse); + flushLatest.current = flushPendingParse; // Text mode: parse the DPQL into the AST, debounced, once the session can read it. const handleDpqlChange = useCallback( @@ -631,11 +681,13 @@ export const ExpressionField = memo( if (next === textRef.current) return; userTypedRef.current = true; setText(next); + // the value does not hold it until it parses + setDraft(next); if (parseTimer.current) clearTimeout(parseTimer.current); pendingParse.current = next; parseTimer.current = setTimeout(() => void flushPendingParse(), PARSE_DEBOUNCE_MS); }, - [flushPendingParse] + [flushPendingParse, setDraft] ); /** Whether this visit to Text mode has already put the AST in the editor. The host's own text for the @@ -672,6 +724,15 @@ export const ExpressionField = memo( return undefined; } if (seededRef.current || userTypedRef.current) return undefined; + /* What was written and does not parse is this visit's text, read again so its error is said again: not the + value written out over it. */ + if (draftRef.current !== undefined) { + seededRef.current = true; + setText(draftRef.current); + pendingParse.current = draftRef.current; + void flushLatest.current(); + return undefined; + } if (!ast?.exp) return undefined; /* Only what DPQL can write is serialized. An operand whose operation is not chosen yet, or that is not filled in, has no text; handed to serialize as diff --git a/src/components/form/fields/template/TemplateField.tsx b/src/components/form/fields/template/TemplateField.tsx index 5311e70f..658793f2 100644 --- a/src/components/form/fields/template/TemplateField.tsx +++ b/src/components/form/fields/template/TemplateField.tsx @@ -28,7 +28,7 @@ import { IReqoreTextareaProps, } from '@qoretechnologies/reqore/dist/components/Textarea'; import { IQorusFormFieldSchemaBase, TQorusType } from '@qoretechnologies/ts-toolkit'; -import { size } from 'lodash'; +import { isEqual, size } from 'lodash'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useUpdateEffect } from 'react-use'; import { @@ -311,6 +311,11 @@ export interface IValueTabsLabels { undo: string; /** The undo's tooltip; `text` is what was typed. */ undoTooltip: (text: string) => string; + /** + * Said on the Value tab while the Expression tab holds text that does not parse: the field keeps the value + * shown here, and the text waits there with its error (qorus#646). + */ + expressionDraftNotUsed?: string; } /** The keyboard a whole number or a number is typed on, on the Value tab. */ @@ -344,6 +349,8 @@ export const DEFAULT_VALUE_TABS_LABELS: IValueTabsLabels = { `${from.charAt(0).toUpperCase()}${from.slice(1)} is converted to ${to} when the value is used; a value that does not convert fails`, undo: 'Undo', undoTooltip: (text) => `Keep "${text}" as a value instead`, + expressionDraftNotUsed: + 'The Expression tab holds text that does not parse, so the field keeps this value until it does.', }; /** A value the host keeps for reuse (qorus#646): saved from one field, offered in every field of its type. */ @@ -864,6 +871,21 @@ const TemplateFieldImpl = memo( const [expressionSeed, setExpressionSeed] = useState( opensInExpressionText ? expressionTextOfValue(value) : null ); + /* What the author wrote on the Expression tab that the field does not hold (it did not parse), and the value + it was written over. The tab's editor goes when the tab is left; the next visit shows the text as it was + written while the field still holds that value, not the value written out (qorus#646). */ + const expressionDraft = useRef<{ text: string; over: unknown } | null>(null); + const valueRef = useRef(value); + valueRef.current = value; + const handleExpressionDraft = useCallback((text: string | undefined) => { + expressionDraft.current = text === undefined ? null : { text, over: valueRef.current }; + }, []); + /** The draft the Expression tab's editor is given back on this visit; null when there is none. */ + const [expressionDraftText, setExpressionDraftText] = useState(null); + /** Whether the Expression tab was left with a draft (see the Value tab's note); read with the value it holds. */ + const [leftWithDraft, setLeftWithDraft] = useState(false); + const hasExpressionDraft = + leftWithDraft && !!expressionDraft.current && isEqual(expressionDraft.current.over, value); const [isTemplateState, setIsTemplate] = useState( tabsKind ? @@ -1256,6 +1278,8 @@ const TemplateFieldImpl = memo( /* What was just typed on the Expression tab is read before the tab goes, and is the expression the field now holds; it was lost with the editor when the tab was left before it was read (qorus#646). */ const flushed = tab === 'expression' ? await expressionFlush.current?.() : undefined; + // left with text that did not parse: the Value tab says the field keeps its value + setLeftWithDraft(tab === 'expression' && !flushed && !!expressionDraft.current); const envelope = flushed ?? (valueIsExpression ? { is_expression: true, value } : undefined); if (next === 'value' || next === 'template') { @@ -1278,6 +1302,11 @@ const TemplateFieldImpl = memo( a seed left from an earlier visit was shown in its place - the value as it was opened, not the expression written since - and leaving the tab saved it (qorus#646). */ setExpressionSeed(!flushed && !valueIsExpression ? expressionTextOfValue(value) : null); + // the text written there that did not parse, while the field still holds what it was written over + const draft = expressionDraft.current; + const keepsDraft = !flushed && !!draft && isEqual(draft.over, value); + if (!keepsDraft) expressionDraft.current = null; + setExpressionDraftText(keepsDraft ? draft.text : null); setInternalIsFunction(true); } setTab(next); @@ -2158,6 +2187,8 @@ const TemplateFieldImpl = memo( : undefined, }} initialText={expressionSeed ?? undefined} + draftText={expressionDraftText ?? undefined} + onDraftChange={handleExpressionDraft} heldAsValue={!isEmptyValue && value !== null && typeof value !== 'object'} componentOverrides={componentOverrides} localTemplates={templates} @@ -2242,6 +2273,18 @@ const TemplateFieldImpl = memo(
{renderControls()} + {/* the author's expression is not lost and not used: said where the value it did not replace is shown */} + {tab === 'value' && hasExpressionDraft ? + + {words.expressionDraftNotUsed} + + : null} {tab === 'value' ? valueBody() : tab === 'template' ? From 249280b846801731619e6cf11e0e39be4c65b5e3 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 00:16:00 +0200 Subject: [PATCH 165/262] fix(dpql-editor): a quoted field is its chip, and a reference ends where the server's tokenizer ends it (qorus#646) The server writes a record field whose name DPQL does not read bare as a quoted field: dpql/serialize gives $record:{create.items[*].id} back as @"create.items[*].id". The editor's pattern knew only @name, so a reference drawn as its chip turned into plain text after a tab switch. A template's bracketed value ended at its first }, so $record:{create.a\{b\}} was a chip for create.a\{b\ followed by the text }. The editor now reads references one character at a time, as Qore's DpqlTokenizer does: a quoted field @"..." (a backslash escapes the next character) is one chip, labeled with the name it stands for, unescaped as the parser reads it, and written back exactly as it was; a bracketed template value takes escapes, nested braces and strings inside, as the tokenizer does. The chip renderer names and describes a quoted field by that name (dpqlFieldName). Found on the way: a field inside a string literal was drawn as a chip, and "@name" exactly was written back as the field @name, its quotes lost. A field in a string is now text; a template in one is still drawn as its chip, as before. Unit: __tests__/dpqlEditor/quotedReferences.test.ts (21 of 33 fail before). Stories: Components/DpqlEditor "With Quoted Field References" and "Quoted Field Typed Out Is A Chip" (both fail before). --- __tests__/dpqlEditor/quotedReferences.test.ts | 197 ++++++++++++ design/SMART_EDITOR_UX.md | 11 +- .../dpqlEditor/DpqlEditor.stories.tsx | 63 ++++ src/components/dpqlEditor/dpqlHelpers.ts | 295 ++++++++++++++---- src/components/dpqlEditor/dpqlTags.ts | 6 +- 5 files changed, 502 insertions(+), 70 deletions(-) create mode 100644 __tests__/dpqlEditor/quotedReferences.test.ts diff --git a/__tests__/dpqlEditor/quotedReferences.test.ts b/__tests__/dpqlEditor/quotedReferences.test.ts new file mode 100644 index 00000000..0739e502 --- /dev/null +++ b/__tests__/dpqlEditor/quotedReferences.test.ts @@ -0,0 +1,197 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A reference the DPQL editor draws as a chip is the one the server's DPQL tokenizer reads (qorus#646). +// +// The server writes a record field whose name is not a bare name as a quoted field, `@"create.items[*].id"`: it is +// how the expression `$record:{create.items[*].id}` comes back from dpql/serialize, so the editor drew a reference +// it had just shown as a chip as plain text after a tab switch. And a template's bracketed value ended at its +// first `}`, so `$record:{create.a\{b\}}` was a chip for `create.a\{b\` and the text `}`. +import { + dpqlSlateConverter, + getTagLabel, + plainTextToSlate, + slateToPlainText, +} from '../../src/components/dpqlEditor/dpqlHelpers'; +import { makeDpqlTagRenderer } from '../../src/components/dpqlEditor/dpqlTags'; +import { rechipTypedReferences } from '../../src/components/smartEditor/typedReferences'; +import { ISlateElement } from '../../src/components/smartEditor/types'; + +/** The chips a line is drawn with: each one's value and label. */ +const chipsIn = (text: string) => + plainTextToSlate(text).flatMap((paragraph) => + paragraph.children + .filter((node): node is ISlateElement => (node as ISlateElement).type === 'tag') + .map(({ value, label }) => ({ value, label })) + ); + +/** A line as it is drawn: its text, with each chip as `[label]`. */ +const drawn = (text: string) => + plainTextToSlate(text) + .map((paragraph) => + paragraph.children + .map((node) => + (node as ISlateElement).type === 'tag' ? + `[${(node as ISlateElement).label}]` + : (node as { text: string }).text + ) + .join('') + ) + .join('\n'); + +describe('a quoted field is drawn as its chip', () => { + // each as dpql/serialize writes it (DpqlSerializer::serializeFieldRef): any name DPQL does not read bare is + // quoted, a backslash doubled and a quote escaped + const serialized: [string, string][] = [ + ['@"create.items[*].id"', 'create.items[*].id'], + ['@"create.items[0].id"', 'create.items[0].id'], + ['@"create.order-id"', 'create.order-id'], + ['@"c.output[0]"', 'c.output[0]'], + ['@"create.a\\\\.b"', 'create.a\\.b'], + ['@"create.a\\\\{b\\\\}"', 'create.a\\{b\\}'], + ['@"say \\"hi\\""', 'say "hi"'], + ['@"order id"', 'order id'], + ]; + + test.each(serialized)('%s is one chip, labeled with the name it stands for', (text, name) => { + expect(chipsIn(text)).toEqual([{ value: text, label: name }]); + expect(drawn(text)).toBe(`[${name}]`); + }); + + test.each(serialized)('%s is written back exactly as it was', (text) => { + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('in an expression, beside operators, other fields and templates', () => { + const text = '@"create.order-id" == 5 AND @name > $local:min OR @"c.output[0]" != "x"'; + + expect(drawn(text)).toBe( + '[create.order-id] == 5 AND [name] > [local: min] OR [c.output[0]] != "x"' + ); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('as an argument, and with an accessor after it', () => { + expect(drawn('length(@"create.items")')).toBe('length([create.items])'); + expect(drawn('@"create.items"[0]')).toBe('[create.items][0]'); + }); + + test('an escaped quote does not end it', () => { + expect(chipsIn('@"a\\"b" == 1')).toEqual([{ value: '@"a\\"b"', label: 'a"b' }]); + }); + + test('one not closed on its line is text, as the server rejects it', () => { + expect(chipsIn('@"create.items == 1')).toEqual([]); + expect(slateToPlainText(plainTextToSlate('@"create.items == 1'))).toBe('@"create.items == 1'); + }); + + test('not inside a word', () => { + expect(chipsIn('user@"x"')).toEqual([]); + }); + + test('its label, for a completion or a chip the server sends without one', () => { + expect(getTagLabel('@"create.order-id"')).toBe('create.order-id'); + expect(getTagLabel('@"a\\\\b"')).toBe('a\\b'); + expect(getTagLabel('@"tab\\tname"')).toBe('tab\tname'); + expect(getTagLabel('@name')).toBe('name'); + }); + + test('the chip says the name, and the field it names describes it', () => { + const renderTag = makeDpqlTagRenderer({ + 'create.order-id': { display_name: 'Order ID', type: { name: 'string' } }, + }); + const tag = (value: string): ISlateElement => ({ + type: 'tag', + value, + children: [{ text: '' }], + }); + + expect(renderTag(tag('@"create.order-id"'))).toMatchObject({ + label: 'Order ID', + icon: 'Database2Line', + }); + expect(renderTag(tag('@"create.order-id"')).tooltip).toBeTruthy(); + // a field the record does not describe is drawn with its name, unescaped, not its quoted text + expect(renderTag(tag('@"create.items[*].id"')).label).toBe('create.items[*].id'); + expect(renderTag(tag('@"say \\"hi\\""')).label).toBe('say "hi"'); + expect(renderTag(tag('@name')).label).toBe('name'); + }); + + test('typed out, it is drawn as its chip once the character after it ends it', () => { + const text = '@"create.order-id" == 1'; + const typed: ISlateElement = { type: 'paragraph', children: [{ text }] }; + const end = '@"create.order-id"'.length; + + // still at its closing quote: left as text, the caret may go on to an accessor + expect(rechipTypedReferences(dpqlSlateConverter, typed, 0, end)).toBeUndefined(); + // the space after it typed: drawn as its chip + const rechipped = rechipTypedReferences(dpqlSlateConverter, typed, 0, end + 1); + expect(rechipped?.children).toEqual(plainTextToSlate(text)[0].children); + expect(rechipped?.children[1]).toMatchObject({ type: 'tag', label: 'create.order-id' }); + }); +}); + +describe("a template's bracketed value ends where the server's does", () => { + test('an escaped brace is part of it', () => { + expect(chipsIn('$record:{create.a\\{b\\}}')).toEqual([ + { value: '$record:{create.a\\{b\\}}', label: 'record: create.a\\{b\\}' }, + ]); + expect(drawn('$record:{create.a\\{b\\}} == 1')).toBe('[record: create.a\\{b\\}] == 1'); + }); + + test('a nested pair of braces is part of it', () => { + expect(chipsIn('$local:{x{y}} == 1')).toEqual([ + { value: '$local:{x{y}}', label: 'local: x{y}' }, + ]); + }); + + test('a string inside it is read whole, a brace in it included', () => { + expect(chipsIn('$local:{a["}"]} == 1')).toEqual([ + { value: '$local:{a["}"]}', label: 'local: a["}"]' }, + ]); + }); + + test('the ones the server reads for a record field', () => { + for (const path of [ + 'create.items[*].id', + 'create.items[0].id', + 'create.order-id', + 'create.a\\.b', + 'c.output[0]', + ]) { + const text = `$record:{${path}}`; + expect(chipsIn(text)).toEqual([{ value: text, label: `record: ${path}` }]); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + } + }); + + test('one not closed on its line is text', () => { + expect(chipsIn('$record:{create.a\\}')).toEqual([]); + expect(chipsIn('$record:{create')).toEqual([]); + }); +}); + +describe('a field in a string literal is text', () => { + test('a string that is a field name keeps its quotes', () => { + // drawn as the field's chip, it was written back as the field `@name`: a string became a reference + expect(chipsIn('@handle == "@name"')).toEqual([{ value: '@handle', label: 'handle' }]); + expect(slateToPlainText(plainTextToSlate('@handle == "@name"'))).toBe('@handle == "@name"'); + }); + + test('in single quotes, beside other words, and over two lines', () => { + expect(chipsIn("'@name' == @x")).toEqual([{ value: '@x', label: 'x' }]); + expect(chipsIn('"hello @name" == @x')).toEqual([{ value: '@x', label: 'x' }]); + expect(chipsIn('"first line\n@name" == @x')).toEqual([{ value: '@x', label: 'x' }]); + }); + + test('an escaped quote does not end the string', () => { + expect(chipsIn('"a \\" @name" == @x')).toEqual([{ value: '@x', label: 'x' }]); + }); + + test('a template in a string is still its chip, and a string that is one keeps its quotes', () => { + expect(drawn('"Hello $local:name" == @x')).toBe('"Hello [local: name]" == [x]'); + const quoted = plainTextToSlate('contains("$local:input", "es")')[0].children; + expect(quoted[1]).toMatchObject({ value: '$local:input', metadata: { quoted: true } }); + expect(slateToPlainText(plainTextToSlate('contains("$local:input", "es")'))).toBe( + 'contains("$local:input", "es")' + ); + }); +}); diff --git a/design/SMART_EDITOR_UX.md b/design/SMART_EDITOR_UX.md index 33371f50..75dc2861 100644 --- a/design/SMART_EDITOR_UX.md +++ b/design/SMART_EDITOR_UX.md @@ -178,9 +178,14 @@ it). ### 6. `dpql/toRichtext` opt-in for accurate plain-text → Slate parsing **Problem.** Our `plainTextToSlate` in `dpqlEditor/dpqlHelpers.ts` is a -client-side regex. It handles `$prefix:value` and `@field` but misses -nested expressions, function calls with templates inside, and quoted -edge cases. The server's `dpql/toRichtext` does this correctly. +client-side reader. It finds the references the server's DPQL tokenizer +reads, as it reads them (qorus#646): `$prefix:value`, `$prefix:{value}` +(ending where the tokenizer's does: a backslash escapes the next character, +braces nest, a string inside is read whole), `@field` and the quoted +`@"field"` the server writes for a name DPQL does not read bare (its chip +labeled with the name, unescaped). A field inside a string literal is +text; a template in one is still drawn as its chip. It does not parse the +expression around them. The server's `dpql/toRichtext` does that. **Fix.** Add an opt-in `useServerParse?: boolean` prop on `DpqlEditor`. When true: diff --git a/src/components/dpqlEditor/DpqlEditor.stories.tsx b/src/components/dpqlEditor/DpqlEditor.stories.tsx index fbe2c157..d1d2e946 100644 --- a/src/components/dpqlEditor/DpqlEditor.stories.tsx +++ b/src/components/dpqlEditor/DpqlEditor.stories.tsx @@ -470,6 +470,69 @@ export const WithMixedContent: Story = { }, }; +/** The labels of the chips the editor draws, in order. */ +const chipLabels = (editable: HTMLElement) => + [...editable.querySelectorAll('.reqore-tag')].map((chip) => + (chip.textContent ?? '').replace(/\uFEFF/g, '').trim() + ); + +/** + * The fields the server writes quoted (qorus#646): `dpql/serialize` writes a record field whose name DPQL does + * not read bare as `@"…"` - a path with `[*]` or an index, a dashed key - which is how `$record:{create.items[*].id}` + * comes back after a tab switch. Each is its chip, as `@name` is, labeled with the name it stands for; a template + * with an escaped brace is one chip; and a field in a string is the string. + */ +export const WithQuotedFieldReferences: Story = { + args: { + value: '@"create.items[*].id" == $record:{create.a\\{b\\}} && @"create.order-id" != "@name"', + onChange: fn(), + }, + parameters: { + docs: { + description: { + story: + 'Renders the DpqlEditor with the quoted fields the server writes for record fields that are not bare names, @"create.items[*].id" and @"create.order-id", a $record template whose value holds escaped braces, and the string "@name". Each field and the template is one chip, labeled with the name it stands for; the string stays text; the text is written back as it was.', + }, + }, + }, + play: async ({ canvasElement, args }) => { + const editable = within(canvasElement).getByRole('textbox'); + + await waitFor(() => + expect(chipLabels(editable)).toEqual([ + 'create.items[*].id', + 'record: create.a\\{b\\}', + 'create.order-id', + ]) + ); + await expect(editable.textContent?.replace(/\uFEFF/g, '')).toContain('!= "@name"'); + // drawn, not rewritten + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + +export const QuotedFieldTypedOutIsAChip: Story = { + args: { value: '', onChange: fn() }, + parameters: { + docs: { + description: { + story: + 'Renders the empty DpqlEditor and types @"create.order-id" == 1 without picking from the list. When the space after the closing quote is typed, the quoted field is drawn as its chip, labeled create.order-id, and the rest is typed after it.', + }, + }, + }, + play: async ({ canvasElement, args }) => { + const editable = within(canvasElement).getByRole('textbox'); + + await userEvent.click(editable); + await userEvent.type(editable, '@"create.order-id" == 1'); + + await waitFor(() => expect(chipLabels(editable)).toEqual(['create.order-id'])); + await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('@"create.order-id" == 1')); + await expect(editable.textContent?.replace(/\uFEFF/g, '')).toMatch(/create\.order-id\s*== 1$/); + }, +}; + /** * Exercises the `useServerParse` opt-in (design doc §6). The editor * calls `dpql/toRichtext` on the LSP and uses the server's structured diff --git a/src/components/dpqlEditor/dpqlHelpers.ts b/src/components/dpqlEditor/dpqlHelpers.ts index e60428f7..7c0446cf 100644 --- a/src/components/dpqlEditor/dpqlHelpers.ts +++ b/src/components/dpqlEditor/dpqlHelpers.ts @@ -1,20 +1,16 @@ // Copyright 2026 Qore Technologies, s.r.o. -// DPQL-specific Slate helpers — recognise `@field` and `$prefix:value` -// patterns in plain text, render them as tag elements, account for +// DPQL-specific Slate helpers — recognise `@field`, `@"field"` and `$prefix:value` +// references in plain text, render them as tag elements, account for // `metadata.quoted` tag spacing in cursor-offset math. The generic // SmartEditor primitive uses these via the `converter` prop on // `DpqlEditor`; the SmartEditor itself has no knowledge of DPQL syntax. import { ISlateConverter, ISlateElement, ISlateText, TSlateNode } from '../smartEditor/types'; -// Combined pattern that matches templates, field refs, or plain text segments. -// Template values without braces match only word chars and dots (not quotes, -// parens, etc.); the `(? { + for (let i = from; i < line.length; i += 1) { + if (line[i] === '\\') { + i += 1; + } else if (line[i] === quote) { + return i + 1; + } } - const lines = text.split('\n'); + return -1; +}; - return lines.map((line) => { - const children: TSlateNode[] = []; - if (!line) { - children.push({ text: '' }); - return { type: 'paragraph' as const, children }; +/** + * Where a template's bracketed value that opens at `from` (its `{`) ends: the index after the `}` that closes it, + * or -1 when the line ends first. As the tokenizer reads it: a backslash escapes the character after it (`\{`, + * `\}`), a brace opens or closes a nested pair, and a string literal inside is read whole. + */ +const endOfBracketed = (line: string, from: number): number => { + let depth = 0; + for (let i = from; i < line.length; i += 1) { + const char = line[i]; + if (char === '\\') { + i += 1; + } else if (char === '"' || char === "'") { + const close = endOfQuoted(line, i + 1, char); + if (close < 0) { + return -1; + } + i = close - 1; + } else if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return i + 1; + } } - let lastIndex = 0; - TOKEN_PATTERN.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = TOKEN_PATTERN.exec(line)) !== null) { - const matchStart = match.index; - const matchEnd = matchStart + match[0].length; - const isQuoted = - matchStart > 0 && - line[matchStart - 1] === '"' && - matchEnd < line.length && - line[matchEnd] === '"'; - const textStart = isQuoted ? matchStart - 1 : matchStart; - const textEnd = isQuoted ? matchEnd + 1 : matchEnd; + } + return -1; +}; + +/** The template that starts at `at` (its `$`), or undefined when none does. */ +const readTemplate = ( + line: string, + at: number +): { end: number; tag: ISlateElement } | undefined => { + TEMPLATE_HEAD.lastIndex = at; + if (!TEMPLATE_HEAD.test(line)) { + return undefined; + } + const valueStart = TEMPLATE_HEAD.lastIndex; + let end: number; + if (line[valueStart] === '{') { + end = endOfBracketed(line, valueStart); + } else { + TEMPLATE_BARE_VALUE.lastIndex = valueStart; + end = TEMPLATE_BARE_VALUE.test(line) ? TEMPLATE_BARE_VALUE.lastIndex : -1; + } + if (end < 0) { + return undefined; + } + const value = line.slice(at, end); + return { end, tag: createTagElement(value, getTagLabel(value)) }; +}; + +/** The character before `at` in `line`, whole: a letter outside the BMP is two code units. */ +const charBefore = (line: string, at: number): string => + at > 0 ? (Array.from(line.slice(Math.max(0, at - 2), at)).pop() ?? '') : ''; - // Every chip has a text node on each side, even an empty one: a chip - // that starts or ends the line otherwise leaves the caret nowhere to go - // but into the chip, where Slate drops whatever is typed. - children.push({ text: line.slice(lastIndex, textStart) }); +/** The field that starts at `at` (its `@`), bare or quoted, or undefined when none does. */ +const readField = (line: string, at: number): { end: number; tag: ISlateElement } | undefined => { + if (BEFORE_NO_FIELD.test(charBefore(line, at))) { + return undefined; + } + let end: number; + if (line[at + 1] === '"') { + end = endOfQuoted(line, at + 2, '"'); + } else { + BARE_FIELD.lastIndex = at; + end = BARE_FIELD.test(line) ? BARE_FIELD.lastIndex : -1; + } + if (end < 0) { + return undefined; + } + const value = line.slice(at, end); + return { end, tag: createTagElement(value, getTagLabel(value)) }; +}; - const fullMatch = match[0]; - if (match[1]) { - const colonIdx = fullMatch.indexOf(':'); - const prefix = fullMatch.slice(1, colonIdx); - const valueText = fullMatch.slice(colonIdx + 1); - const displayLabel = - valueText.startsWith('{') && valueText.endsWith('}') - ? valueText.slice(1, -1) - : valueText; - children.push( - createTagElement( - fullMatch, - `${prefix}: ${displayLabel}`, - isQuoted ? { quoted: true } : undefined - ) - ); - } else if (match[2]) { - const fieldName = fullMatch.slice(1); - children.push(createTagElement(fullMatch, fieldName)); +/** + * The references in `line`, and the string literal still open at its end. `quote` is the one open at its start: a + * DPQL string literal may go on over several lines. + * + * A field in a string literal is text: `"@name"` is a string, and drawn as a chip it was written back as the field + * `@name`, its quotes lost. A template in one is still drawn as its chip, as it was before, and a string that is + * nothing but a template is drawn as the template's chip, quotes included (`metadata.quoted`), so + * `contains("$local:input", "es")` reads as it does with the reference unquoted. + */ +const readReferences = ( + line: string, + quote: string | undefined +): { references: IDpqlReference[]; quote: string | undefined } => { + const references: IDpqlReference[] = []; + let open = quote; + // where the string literal open now starts, when it opened on this line + let openedAt = -1; + let i = 0; + while (i < line.length) { + const char = line[i]; + if (open) { + if (char === '\\') { + i += 2; + } else if (char === open) { + open = undefined; + i += 1; + } else if (char === '$') { + const template = readTemplate(line, i); + if (!template) { + i += 1; + } else if (open === '"' && openedAt === i - 1 && line[template.end] === '"') { + template.tag.metadata = { quoted: true }; + references.push({ start: i - 1, end: template.end + 1, tag: template.tag }); + open = undefined; + i = template.end + 1; + } else { + references.push({ start: i, end: template.end, tag: template.tag }); + i = template.end; + } + } else { + i += 1; + } + } else if (char === '"' || char === "'") { + open = char; + openedAt = i; + i += 1; + } else if (char === '$' || char === '@') { + const reference = char === '$' ? readTemplate(line, i) : readField(line, i); + if (reference) { + references.push({ start: i, end: reference.end, tag: reference.tag }); + i = reference.end; + } else { + i += 1; } + } else { + i += 1; + } + } + return { references, quote: open }; +}; - lastIndex = textEnd; +/** + * Convert plain DPQL text to Slate elements: each reference (`$prefix:value`, `$prefix:{value}`, `@field`, + * `@"field"`) is a tag element whose value is its text exactly, and everything else is text. + */ +export function plainTextToSlate(text: string): ISlateElement[] { + if (!text) { + return [{ type: 'paragraph', children: [{ text: '' }] }]; + } + let quote: string | undefined; + + return text.split('\n').map((line) => { + const read = readReferences(line, quote); + quote = read.quote; + // Every chip has a text node on each side, even an empty one: a chip + // that starts or ends the line otherwise leaves the caret nowhere to go + // but into the chip, where Slate drops whatever is typed. + const children: TSlateNode[] = []; + let lastIndex = 0; + for (const { start, end, tag } of read.references) { + children.push({ text: line.slice(lastIndex, start) }); + children.push(tag); + lastIndex = end; } children.push({ text: line.slice(lastIndex) }); return { type: 'paragraph' as const, children }; @@ -218,10 +344,49 @@ export function isTagCompletion(insertText: string): boolean { return insertText.startsWith('@') || insertText.startsWith('$'); } -/** Display label for a completion being inserted as a tag. */ +/** + * The name a quoted field's text stands for, as the server's parser reads it: a backslash escapes the character + * after it (`\"` is `"`, `\\` is `\`), and `\n`, `\r` and `\t` are a newline, a return and a tab. + */ +const unescapeDpqlString = (content: string): string => { + let result = ''; + let escaped = false; + for (const char of content) { + if (escaped) { + result += + char === 'n' ? '\n' + : char === 'r' ? '\r' + : char === 't' ? '\t' + : char; + escaped = false; + } else if (char === '\\') { + escaped = true; + } else { + result += char; + } + } + return escaped ? `${result}\\` : result; +}; + +/** + * The name of the field a field reference stands for - `@name` is `name`, and the quoted `@"order-id"` is + * `order-id`, unescaped as the server reads it - or undefined when `value` is not a field reference. + */ +export const dpqlFieldName = (value: string): string | undefined => { + if (value.startsWith('@"') && value.length > 2 && value.endsWith('"')) { + return unescapeDpqlString(value.slice(2, -1)); + } + return value.startsWith('@') ? value.slice(1) : undefined; +}; + +/** + * What a reference's chip says: a field's name (`dpqlFieldName`), or a template's prefix and value + * (`$local:{a.b}` is `local: a.b`). + */ export function getTagLabel(value: string): string { - if (value.startsWith('@')) { - return value.slice(1); + const field = dpqlFieldName(value); + if (field !== undefined) { + return field; } if (value.startsWith('$')) { const colonIdx = value.indexOf(':'); diff --git a/src/components/dpqlEditor/dpqlTags.ts b/src/components/dpqlEditor/dpqlTags.ts index be18f924..634c1181 100644 --- a/src/components/dpqlEditor/dpqlTags.ts +++ b/src/components/dpqlEditor/dpqlTags.ts @@ -13,6 +13,7 @@ import { TTemplateMeta, } from '../../helpers/templates'; import { ISlateElement } from '../smartEditor/types'; +import { dpqlFieldName } from './dpqlHelpers'; import { IDpqlFieldMeta } from './useDpqlSession'; /** Color scheme for tag rendering, keyed by template prefix. */ @@ -89,8 +90,9 @@ export function makeDpqlTagRenderer( const tagValue = tag.value?.toString(); if (!tagValue) return {}; - if (tagValue.startsWith('@')) { - const fieldName = tagValue.slice(1); + // a field, `@name` or quoted `@"order-id"`: drawn with its name, and described by the record's field of it + const fieldName = dpqlFieldName(tagValue); + if (fieldName !== undefined) { const meta = fieldMeta[fieldName]; // the field's name and type head the card, and its description is drawn under them const tooltip = meta From 38d812a348f14b4146abe339bebdc4274163a1ec Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 00:29:14 +0200 Subject: [PATCH 166/262] fix(smart-editor): an expression is written in reqore's mono stack, at the size of the field's text (qorus#646) The DPQL text editor set its font to the bare generic `monospace`, which is the browser's default fixed-width font: Courier in Chrome on macOS unless the user changed it. So "202" read larger on a field's Expression tab than on its Value tab, in a face no other text in the IDE uses. Its completion rows, its hover and signature-help docs, a compact row's code size and line-count notes, the log view and a ticket's inline code did the same. Each now takes reqore's mono stack (ReqoreFonts.mono, in reqore since 0.72.7, below the peer range); the compact row's MONO_FONT_STACK is that stack too, so the engine's literal text and the editor's are one face. The editor's frame was also given a fixed 13px, larger than a small field's 12px text. Dropping it is not enough: the panel's own size is larger again (15px), and the frame's 2.4em floor then made it 36px tall against the Value tab's 32px. The frame now takes the size its text is written in (CONTROL_TEXT_FROM_SIZE for the editor's size). Story: Form/FormEngine "Expression Tab Text In The Mono Stack At The Fields Size" requires the mono stack, the Value tab's font size and line height, nothing in the frame larger, and the Value tab's frame height (fails on the bare monospace; with the 13px kept, on the larger frame text; with no size, 36px against 32px). Unit: __tests__/monoFontStack.test.ts finds any bare `monospace` in src (fails before on the 9 sites). --- __tests__/monoFontStack.test.ts | 51 +++++++++++++++++ src/components/form/engine/CompactRow.tsx | 5 +- .../form/engine/FormEngine.stories.tsx | 56 ++++++++++++++++++- .../form/engine/compactRowStyles.ts | 17 ++---- src/components/log/Log.tsx | 7 ++- src/components/smartEditor/MarkdownDoc.tsx | 6 +- src/components/smartEditor/SmartEditor.tsx | 13 ++++- src/components/smartEditor/completionStyle.ts | 5 +- src/components/ticketThread/TicketThread.tsx | 3 +- 9 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 __tests__/monoFontStack.test.ts diff --git a/__tests__/monoFontStack.test.ts b/__tests__/monoFontStack.test.ts new file mode 100644 index 00000000..122967ba --- /dev/null +++ b/__tests__/monoFontStack.test.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * qorus#646: code-like text is written in reqore's mono stack (`ReqoreFonts.mono`: ui-monospace, SFMono-Regular, + * Menlo, …), never in the bare generic `monospace`. The generic keyword is the browser's default fixed-width font - + * Courier in Chrome on macOS unless the user changed it - so the expression editor wrote "202" larger than the + * field's Value tab beside it, in a face no other text in the IDE uses. + * + * Read: every source under `src`, stories included (what they draw is reviewed as the product). A stack that ends + * in `monospace` as its fallback is fine; `monospace` alone is not. + */ +const root = join(__dirname, '..'); + +/** Every TypeScript source under a directory, relative to the root, tests left out. */ +const sourcesUnder = (dir: string): string[] => + readdirSync(join(root, dir), { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? sourcesUnder(join(dir, entry.name)) + : /\.(ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [join(dir, entry.name)] + : [] + ); + +/** A font family that is the bare generic alone: in a style object, or in CSS. */ +const BARE_MONOSPACE = [ + /fontFamily:\s*(['"`])monospace\1/, + /font-family:\s*monospace\s*(;|$|!important)/, +]; + +describe('code-like text is written in reqore mono stack', () => { + it('in no source is the font family the bare generic monospace', () => { + const offences = sourcesUnder('src').flatMap((file) => + readFileSync(join(root, file), 'utf8') + .split('\n') + .flatMap((line, index) => + BARE_MONOSPACE.some((pattern) => pattern.test(line)) ? [`${file}:${index + 1}`] : [] + ) + ); + expect(offences).toEqual([]); + }); + + it('reads a bare generic as one, and a stack that falls back to it as none', () => { + const bare = (line: string) => BARE_MONOSPACE.some((pattern) => pattern.test(line)); + + expect(bare("style={{ fontFamily: 'monospace' }}")).toBe(true); + expect(bare(' font-family: monospace;')).toBe(true); + expect(bare('fontFamily: ReqoreFonts.mono,')).toBe(false); + expect(bare(' font-family: ui-monospace, SFMono-Regular, Menlo, monospace;')).toBe(false); + }); +}); diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 0653bf93..6f7da1f9 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -38,6 +38,7 @@ import { FocusedEditing } from '../../FocusedEditing'; import { CompactRowContext } from './compactRowContext'; import { revealRow } from './revealRow'; import { + MONO_FONT_STACK, StyledActionSlot, StyledCardHeading, StyledCardLabel, @@ -1491,7 +1492,7 @@ export const CompactRow = memo( className='options-readfirst-label-code-size' size='tiny' effect={{ opacity: rowInks.noteOpacity, textAlign: 'left' }} - style={{ fontFamily: 'monospace' }} + style={{ fontFamily: MONO_FONT_STACK }} > {`${formatCodeChars(chars)} ${formatCodeLines(lines)}`} @@ -2356,7 +2357,7 @@ export const CompactRow = memo( className='options-readfirst-label-lines' size='tiny' effect={{ opacity: rowInks.noteOpacity, textAlign: 'left' }} - style={{ fontFamily: 'monospace' }} + style={{ fontFamily: MONO_FONT_STACK }} > {lineCountNote} diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index 1cafc6cc..cce06c7d 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -1,5 +1,6 @@ import { ReqoreButton, + ReqoreFonts, ReqoreInput, ReqoreP, ReqoreTag, @@ -868,7 +869,10 @@ const CodeEditorStandin = ({ size?: TSizes; }) => (
-
+
syntax: {String(language ?? 'plain')}
family.replace(/["'\s]/g, ''); + +export const ExpressionTabTextInTheMonoStackAtTheFieldsSize: Story = { + parameters: { + docs: { + description: { + story: + "Renders the form and opens Quantity (12) on the Value tab, then on the Expression tab. The expression is written in reqore's mono stack (ui-monospace, SFMono-Regular, …), not the browser's default fixed-width font, and at the size and line height the Value tab's text is written in: nothing in the editor is drawn larger (qorus#646, David: \"202\" was larger on the Expression tab).", + }, + }, + }, + render: () => , + play: async () => { + const row = await editOption('quantity'); + const valueText = await waitFor(() => { + const el = row.querySelector('.value-tab-text [data-slate-editor]'); + expect(el, 'the Value tab text').toBeTruthy(); + return el as HTMLElement; + }); + const { fontSize, lineHeight } = getComputedStyle(valueText); + const valueFrameHeight = Math.round( + (valueText.closest('.reqore-panel') as HTMLElement).getBoundingClientRect().height + ); + + await chooseTab(row, 'expression'); + await waitFor(() => expect(expressionTextOf('quantity')).toBe('12')); + const editor = optionRow('quantity').querySelector( + '.expression-field [data-slate-editor]' + ) as HTMLElement; + const drawn = getComputedStyle(editor); + + expect(fontStack(drawn.fontFamily)).toBe(fontStack(ReqoreFonts.mono)); + expect({ fontSize: drawn.fontSize, lineHeight: drawn.lineHeight }).toEqual({ + fontSize, + lineHeight, + }); + // its frame as tall as the Value tab's, for the one line both hold + const frameHeight = (el: HTMLElement) => + Math.round((el.closest('.reqore-panel') as HTMLElement).getBoundingClientRect().height); + expect(frameHeight(editor)).toBe(valueFrameHeight); + // and nothing the editor holds or is framed by is written larger than its text + const panel = editor.closest('.reqore-panel') as HTMLElement; + const larger = [panel, ...panel.querySelectorAll('*')] + .filter((el) => parseFloat(getComputedStyle(el).fontSize) > parseFloat(fontSize)) + .map((el) => `${el.className || el.tagName}: ${getComputedStyle(el).fontSize}`); + expect(larger).toEqual([]); + }, +}; + export const ExpressionClearedEmptiesTheValue: Story = { parameters: { docs: { diff --git a/src/components/form/engine/compactRowStyles.ts b/src/components/form/engine/compactRowStyles.ts index 2904f986..62efea95 100644 --- a/src/components/form/engine/compactRowStyles.ts +++ b/src/components/form/engine/compactRowStyles.ts @@ -1,4 +1,4 @@ -import { ReqoreP, ReqorePanel } from '@qoretechnologies/reqore'; +import { ReqoreFonts, ReqoreP, ReqorePanel } from '@qoretechnologies/reqore'; import { GAP_FROM_SIZE, TSizes } from '@qoretechnologies/reqore/dist/constants/sizes'; import styled, { css } from 'styled-components'; @@ -522,17 +522,12 @@ export const StyledRowInset = styled.div` `; /** - * The stack literal text renders in: source, ids, tokens, data strings. - * - * Defined HERE rather than taken from reqore's `ReqoreFonts.mono`, which is the - * same stack. Reqore is a peer dependency at `>=0.71.16` and that export arrived - * later, so reading it would crash every consumer inside the declared range — - * the whole form preview lost over a font choice. One local constant costs - * nothing and keeps the code preview and the schema view's data values - * identical, which is the only reason to share it in the first place. + * The stack literal text renders in: source, ids, tokens, data strings - reqore's own (`ReqoreFonts.mono`), as + * the expression editor's, so the code preview, the schema view's data values and the editor's text are one face. + * It has been in reqore since 0.72.7, below the peer range (`>=0.74.0`). Never the bare generic `monospace`: + * that is the browser's default fixed-width font (Courier in Chrome on macOS), larger than the text beside it. */ -export const MONO_FONT_STACK = - "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace"; +export const MONO_FONT_STACK = ReqoreFonts.mono; // A collapsed code-block preview shown under the value summary for a // `code-editor` field. Multi-line, monospace, subtle background — matches diff --git a/src/components/log/Log.tsx b/src/components/log/Log.tsx index ca84de0c..48e5aafd 100644 --- a/src/components/log/Log.tsx +++ b/src/components/log/Log.tsx @@ -1,6 +1,7 @@ import { ReqoreButton, ReqoreControlGroup, + ReqoreFonts, ReqoreInput, ReqoreParagraph, } from '@qoretechnologies/reqore'; @@ -210,7 +211,7 @@ export const ReqraftLog = ({ > {count(filteredMessages) === 0 ? - + No messages : null} @@ -220,7 +221,7 @@ export const ReqraftLog = ({ {showTimestamps && ( {timestamp} @@ -230,7 +231,7 @@ export const ReqraftLog = ({ size={size} key={index} onClick={onMessageClick ? () => handleMessageClick(message) : undefined} - style={{ fontFamily: 'monospace' }} + style={{ fontFamily: ReqoreFonts.mono }} className='reqraft-log-message' {...paragraphProps} > diff --git a/src/components/smartEditor/MarkdownDoc.tsx b/src/components/smartEditor/MarkdownDoc.tsx index 00a4e4dc..c6ec2780 100644 --- a/src/components/smartEditor/MarkdownDoc.tsx +++ b/src/components/smartEditor/MarkdownDoc.tsx @@ -10,7 +10,7 @@ // One component, three call sites — keeps the doc styling consistent and // in one place. -import { useReqoreTheme } from '@qoretechnologies/reqore'; +import { ReqoreFonts, useReqoreTheme } from '@qoretechnologies/reqore'; import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors'; import ReactMarkdown from 'react-markdown'; import styled from 'styled-components'; @@ -86,7 +86,7 @@ const StyledMarkdown = styled.div` /* Inline code → subtle chip. */ code { - font-family: monospace; + font-family: ${ReqoreFonts.mono}; font-size: 11px; background: rgba(255, 255, 255, 0.08); padding: 1px 4px; @@ -159,7 +159,7 @@ const StyledMarkdown = styled.div` const StyledPlaintext = styled.pre` margin: 0; white-space: pre-wrap; - font-family: monospace; + font-family: ${ReqoreFonts.mono}; `; export const MarkdownDoc = ({ diff --git a/src/components/smartEditor/SmartEditor.tsx b/src/components/smartEditor/SmartEditor.tsx index f2b68a01..d7a06bc3 100644 --- a/src/components/smartEditor/SmartEditor.tsx +++ b/src/components/smartEditor/SmartEditor.tsx @@ -4,12 +4,14 @@ import { ReqoreButton, ReqoreCallout, ReqoreControlGroup, + ReqoreFonts, ReqoreMessage, ReqorePanel, ReqoreSpinner, useReqoreTheme, } from '@qoretechnologies/reqore'; import { ReqorePopover } from '@qoretechnologies/reqore/dist/components/Popover'; +import { CONTROL_TEXT_FROM_SIZE } from '@qoretechnologies/reqore/dist/constants/sizes'; import { ReqoreRichTextEditor, TReqoreRichTextEditorRef, @@ -199,7 +201,7 @@ const SignatureHelpPill = memo(({ signature, position }: ISignatureHelpPillProps @@ -634,8 +636,13 @@ export const SmartEditor = forwardRef diff --git a/src/components/smartEditor/completionStyle.ts b/src/components/smartEditor/completionStyle.ts index 8ccdae15..3193b763 100644 --- a/src/components/smartEditor/completionStyle.ts +++ b/src/components/smartEditor/completionStyle.ts @@ -1,4 +1,5 @@ // Copyright 2026 Qore Technologies, s.r.o. +import { ReqoreFonts } from '@qoretechnologies/reqore'; import { IReqoreCustomTheme } from '@qoretechnologies/reqore/dist/constants/theme'; import { TReqoreDropdownItem } from '@qoretechnologies/reqore/dist/components/Dropdown/list'; import type React from 'react'; @@ -16,8 +17,8 @@ import { COMPLETION_KIND_CHIPS } from './useLspAutocomplete'; /** The lists' own color. */ export const COMPLETION_LIST_THEME: IReqoreCustomTheme = { main: '#1e0d29' }; -/** A row's text: monospace, as what it writes. Module-scoped, so a memoized row keeps its reference. */ -export const COMPLETION_ITEM_STYLE: React.CSSProperties = { fontFamily: 'monospace' }; +/** A row's text: reqore's mono stack, as what it writes. Module-scoped, so a memoized row keeps its reference. */ +export const COMPLETION_ITEM_STYLE: React.CSSProperties = { fontFamily: ReqoreFonts.mono }; /** The LSP completion kinds a template is drawn as: a record's field, and any other template. */ export const FIELD_KIND = 5; diff --git a/src/components/ticketThread/TicketThread.tsx b/src/components/ticketThread/TicketThread.tsx index d7142b9c..a10ff663 100644 --- a/src/components/ticketThread/TicketThread.tsx +++ b/src/components/ticketThread/TicketThread.tsx @@ -4,6 +4,7 @@ import { ReqoreButton, ReqoreControlGroup, ReqoreEmptyState, + ReqoreFonts, ReqoreIcon, ReqoreP, ReqoreSkeleton, @@ -344,7 +345,7 @@ const StyledMarkdown = styled.div` margin: 2px 0; } code { - font-family: monospace; + font-family: ${ReqoreFonts.mono}; background: ${({ $code }) => $code}; padding: 1px 5px; border-radius: 4px; From 6211b79b1565d07512b209544475901423ca4560 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 04:18:09 +0200 Subject: [PATCH 167/262] fix(templates): a template's braced value ends where Qorus ends it: braces nest, \{ \} \\ are escapes (qorus#646) The template grammar ended a braced value at its first `}`, so `$record:{prices{*}}` - a wildcard written as a template - was drawn as the chip "record: prices{*" and a stray "}", in the Value and Visual tabs and a read row, and written into an expression cut. A braced segment now ends where Qorus's template engine ends it (UserApi _priv_get_template_bracket_arg), as the PO confirmed: braces balance, so `{*}` is written unescaped; `\{`, `\}` and `\\` are escapes, decoded first; a key literally named `{*}` is written `$record:{h.\\{*}}`; a lone brace in a key is escaped. No pattern can count braces, so the grammar is read one character at a time (templateTokenSpans), and every reader of it - isCompleteTemplateToken, hasTextAroundATemplate, textAroundATemplate, the editor's chips and the text written into an expression - asks the same scan. Unit: __tests__/templateTokenBraces.test.ts (6 of 8 fail before; the 2 that pass are what the grammar already took: colons, spaces, dashes, quotes, apostrophes, and an unbalanced value). --- __tests__/templateTokenBraces.test.ts | 100 ++++++++++++++ .../form/fields/template/writtenAsText.ts | 4 +- src/helpers/templateText.ts | 6 +- src/helpers/templates.ts | 122 ++++++++++++++++-- 4 files changed, 213 insertions(+), 19 deletions(-) create mode 100644 __tests__/templateTokenBraces.test.ts diff --git a/__tests__/templateTokenBraces.test.ts b/__tests__/templateTokenBraces.test.ts new file mode 100644 index 00000000..66e784cc --- /dev/null +++ b/__tests__/templateTokenBraces.test.ts @@ -0,0 +1,100 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { describe, expect, it } from 'vitest'; +import { expressionTextOfValue } from '../src/components/form/fields/template/writtenAsText'; +import { templateTextSegments, templateTextToNodes } from '../src/helpers/templateText'; +import { + hasTextAroundATemplate, + isCompleteTemplateToken, + templateTokenSpans, + textAroundATemplate, +} from '../src/helpers/templates'; + +/* + * A template's braced value ends where Qorus ends it (qorus#646): braces nest, and `\{`, `\}` and `\\` are a brace + * or a backslash of the value (UserApi `_priv_get_template_bracket_arg`). The grammar ended a value at its first + * `}`, so `$record:{prices{*}}` - the wildcard written as a template - was the chip "record: prices{*" and a + * stray "}". + */ +describe("a template's braced value", () => { + it('holds nested braces', () => { + expect(isCompleteTemplateToken('$record:{prices{*}}')).toBe(true); + expect(isCompleteTemplateToken('$record:{prices{*}.amount}')).toBe(true); + expect(isCompleteTemplateToken('$data:{a{b{c}}}')).toBe(true); + }); + + it('holds an escaped brace or backslash as a character of the value', () => { + expect(isCompleteTemplateToken('$record:{create.a\\{b\\}}')).toBe(true); + expect(isCompleteTemplateToken('$record:{path\\\\}')).toBe(true); + }); + + // the PO's rule (qorus#646): `\{`, `\}` and `\\` are escapes, decoded first; braces balance, so `{*}` is + // written unescaped; a key literally named `{*}` is the test path `\{*}`, written `$record:{h.\\{*}}`; a lone + // brace in a key is escaped + it("reads the PO's cases as Qorus does", () => { + expect(isCompleteTemplateToken('$record:{h{*}}')).toBe(true); + expect(isCompleteTemplateToken('$record:{h.\\\\{*}}')).toBe(true); + expect(isCompleteTemplateToken('$record:{a\\{b}')).toBe(true); + // a lone brace written unescaped does not balance: no template + expect(isCompleteTemplateToken('$record:{a{b}')).toBe(false); + }); + + it('is no template while it is not closed', () => { + expect(isCompleteTemplateToken('$record:{prices{*}')).toBe(false); + // an escaped brace does not close it + expect(isCompleteTemplateToken('$record:{create.a\\}')).toBe(false); + expect(templateTokenSpans('x $record:{prices{*} y')).toEqual([]); + }); + + it('keeps what the grammar took before: colons, spaces, dashes, quotes and apostrophes', () => { + for (const token of [ + '$data:{deep:test:list}', + '$data:{id.Created by.name}', + '$data:{abc.Multi-select}', + "$data:{Customer's email}", + '$qore-expr:{1 + 2}', + '$local:input', + '$local:a:{b}', + ]) { + expect(isCompleteTemplateToken(token)).toBe(true); + } + for (const notOne of [ + 'x $data:{a.b}', + '$data:{a.b}.csv', + '$foo: hello', + '$5:00 fee', + '$local:', + ]) { + expect(isCompleteTemplateToken(notOne)).toBe(false); + } + }); + + it('is one template in text, with the text around it', () => { + expect(templateTokenSpans('SUP-$record:{prices{*}} kg')).toEqual([[4, 23]]); + expect(hasTextAroundATemplate('$record:{prices{*}} kg')).toBe(true); + expect(hasTextAroundATemplate('$record:{prices{*}}')).toBe(false); + expect(textAroundATemplate('$record:{prices{*}} kg $record:{a\\}b}')).toEqual({ + text: ['kg'], + templates: 2, + }); + }); + + it('is one chip, labeled with its whole value', () => { + const [line] = templateTextToNodes('$record:{prices{*}} kg'); + const tags = line.children.filter((node) => 'type' in node && node.type === 'tag'); + expect(tags).toEqual([ + expect.objectContaining({ value: '$record:{prices{*}}', label: 'record: prices{*}' }), + ]); + expect(line.children[line.children.length - 1]).toEqual({ text: ' kg' }); + + expect(templateTextSegments('$record:{prices{*}}')).toEqual([ + { kind: 'tag', value: '$record:{prices{*}}', text: 'record: prices{*}' }, + ]); + }); + + it('is written into an expression whole', () => { + expect(expressionTextOfValue('$record:{prices{*}}')).toBe('$record:{prices{*}}'); + expect(expressionTextOfValue('SUP-$record:{prices{*}}')).toBe( + 'concat("SUP-", $record:{prices{*}})' + ); + }); +}); diff --git a/src/components/form/fields/template/writtenAsText.ts b/src/components/form/fields/template/writtenAsText.ts index fe6743b9..3fa5ec4b 100644 --- a/src/components/form/fields/template/writtenAsText.ts +++ b/src/components/form/fields/template/writtenAsText.ts @@ -23,7 +23,7 @@ import { findTemplate, isCompleteTemplateToken, isValueTemplate, - TEMPLATE_TOKEN_SOURCE, + splitAtTemplateTokens, } from '../../../../helpers/templates'; /** Scalar types whose value can be written as text: whole numbers, numbers, yes/no. Not a date, which has @@ -84,7 +84,7 @@ export const expressionTextOfValue = (value: unknown): string => { if (text === '') return ''; if (isCompleteTemplateToken(text)) return String(text); const plain: string = text; - const parts = plain.split(new RegExp(`(${TEMPLATE_TOKEN_SOURCE})`)).filter((part) => part !== ''); + const parts = splitAtTemplateTokens(plain); if (parts.length === 1) return JSON.stringify(plain); return `concat(${parts.map((part) => (isCompleteTemplateToken(part) ? part : JSON.stringify(part))).join(', ')})`; }; diff --git a/src/helpers/templateText.ts b/src/helpers/templateText.ts index 91d18bcf..d42d1bfb 100644 --- a/src/helpers/templateText.ts +++ b/src/helpers/templateText.ts @@ -9,7 +9,7 @@ import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea'; import { getTagLabel } from '../components/dpqlEditor/dpqlHelpers'; import { IRichtextSegment } from './common'; -import { describeTemplateReference, TEMPLATE_TOKEN_SOURCE } from './templates'; +import { describeTemplateReference, templateTokenSpans } from './templates'; /* Stated here rather than borrowed from Reqore's editor props: those resolve to Reqore's `CustomElement`, which it does not export, so the published @@ -66,7 +66,7 @@ export const templateChipLabel = (templates: IReqoreFormTemplates | undefined, v * Every value the catalogue offers, longest first. * * A host may write its references in a grammar reqraft has never seen — a Qorus - * test spells one `$._case.mode`, which `TEMPLATE_TOKEN_SOURCE` cannot match + * test spells one `$._case.mode`, which `templateTokenSpans` cannot match * because it has no `$key:` at all. Asking the field's own list which strings * it NAMES settles that without reqraft guessing at anybody's syntax, and it is * the same question the whole-value branch of the read row already asks @@ -196,7 +196,7 @@ const lineReferenceSpans = ( const declared = grammarSource(tokenGrammar); const candidates: ITokenSpan[] = [ ...(declared ? patternSpans(line, declared) : []), - ...patternSpans(line, TEMPLATE_TOKEN_SOURCE), + ...templateTokenSpans(line).map(([start, end]) => ({ start, end })), ...catalogueValues(templates).flatMap((literal) => literalSpans(line, literal)), ].sort((a, b) => a.start - b.start || b.end - a.end); diff --git a/src/helpers/templates.ts b/src/helpers/templates.ts index 260caf1e..68f2f3b3 100644 --- a/src/helpers/templates.ts +++ b/src/helpers/templates.ts @@ -95,19 +95,107 @@ export const getTemplateValue = (value: string): string => { * LOOSER (starts with `$`, has `:`): it answers "does the user mean a * template here" while typing; use the strict check below when a decision * must not misfire on user-typed dollar-strings. + * + * A braced segment ends where Qorus ends it (UserApi `_priv_get_template_bracket_arg`, qorus#646): braces nest, + * and `\{`, `\}` and `\\` are a brace or a backslash of the value, not one that opens or closes it. So + * `$record:{prices{*}}` - a wildcard written as a template - is one token; a pattern ending the value at its first + * `}` drew the chip "record: prices{*" and a stray "}". No pattern can count braces, so the grammar is read one + * character at a time (`templateTokenSpans`). */ -export const TEMPLATE_TOKEN_SOURCE = - '\\$[A-Za-z_][\\w-]*(?::(?:\\{[^}]*\\}|[A-Za-z_][\\w-]*))+'; +const TEMPLATE_KEY = /[A-Za-z_][\w-]*/y; +const TEMPLATE_WORD_SEGMENT = /[A-Za-z_][\w-]*/y; + +/** Where a braced segment that opens at `from` (its `{`) ends: the index after the `}` that closes it, or -1. */ +const endOfBracedSegment = (text: string, from: number): number => { + let depth = 0; + for (let i = from; i < text.length; i += 1) { + const char = text[i]; + if (char === '\\' && ['{', '}', '\\'].includes(text[i + 1])) { + i += 1; + } else if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return i + 1; + } + } + } + return -1; +}; -const COMPLETE_TEMPLATE_TOKEN = new RegExp(`^${TEMPLATE_TOKEN_SOURCE}$`); +/** Where the template token that starts at `at` (its `$`) ends, or -1 when none starts there. */ +const endOfTemplateToken = (text: string, at: number): number => { + if (text[at] !== '$') { + return -1; + } + TEMPLATE_KEY.lastIndex = at + 1; + if (!TEMPLATE_KEY.test(text)) { + return -1; + } + let end = TEMPLATE_KEY.lastIndex; + let segments = 0; + // `:` once or more; a `:` with no segment after it is not part of the token + while (text[end] === ':') { + let segmentEnd: number; + if (text[end + 1] === '{') { + segmentEnd = endOfBracedSegment(text, end + 1); + } else { + TEMPLATE_WORD_SEGMENT.lastIndex = end + 1; + segmentEnd = TEMPLATE_WORD_SEGMENT.test(text) ? TEMPLATE_WORD_SEGMENT.lastIndex : -1; + } + if (segmentEnd < 0) { + break; + } + end = segmentEnd; + segments += 1; + } + return segments ? end : -1; +}; + +/** Every template token in `text`, in order and apart: each `[start, end)`. */ +export const templateTokenSpans = (text: string): Array<[number, number]> => { + const spans: Array<[number, number]> = []; + for (let at = text.indexOf('$'); at !== -1; at = text.indexOf('$', at)) { + const end = endOfTemplateToken(text, at); + if (end > 0) { + spans.push([at, end]); + at = end; + } else { + at += 1; + } + } + return spans; +}; + +/** The record field a template names - `$record:{orders[*].id}` is `orders[*].id` - when it is that template alone + * (one braced segment, nothing after it); otherwise undefined. */ +export const recordFieldOfTemplate = (token: string): string | undefined => { + const prefix = '$record:'; + return ( + token.startsWith(`${prefix}{`) && endOfBracedSegment(token, prefix.length) === token.length + ) ? + token.slice(prefix.length + 1, -1) + : undefined; +}; + +/** `text` cut at its template tokens: the pieces of text and the tokens, in order, none of them empty. */ +export const splitAtTemplateTokens = (text: string): string[] => { + const parts: string[] = []; + let last = 0; + templateTokenSpans(text).forEach(([start, end]) => { + parts.push(text.slice(last, start), text.slice(start, end)); + last = end; + }); + parts.push(text.slice(last)); + return parts.filter((part) => part !== ''); +}; /** STRICT whole-value check: the entire string is one well-formed template * token (`:{id.path}`, `:item`, …) — nothing before or after. * `'$foo: hello'` passes the loose check but not this one. */ export const isCompleteTemplateToken = (value?: unknown): value is string => - typeof value === 'string' && COMPLETE_TEMPLATE_TOKEN.test(value); - -const TEMPLATE_TOKEN_ANYWHERE = new RegExp(TEMPLATE_TOKEN_SOURCE); + typeof value === 'string' && endOfTemplateToken(value, 0) === value.length; /** * Text with a template in it that is not the template alone: `$record:{pos} Stk.`, `SUP-$record:{pos}`, @@ -115,7 +203,9 @@ const TEMPLATE_TOKEN_ANYWHERE = new RegExp(TEMPLATE_TOKEN_SOURCE); * hold it (qorus#646). */ export const hasTextAroundATemplate = (value?: unknown): value is string => - typeof value === 'string' && TEMPLATE_TOKEN_ANYWHERE.test(value) && !isCompleteTemplateToken(value); + typeof value === 'string' && + templateTokenSpans(value).length > 0 && + !isCompleteTemplateToken(value); /** The text around the templates in a value that is not one template alone, and how many templates it holds. */ export interface ITextAroundATemplate { @@ -133,13 +223,17 @@ export const textAroundATemplate = (value?: unknown): ITextAroundATemplate | und if (!hasTextAroundATemplate(value)) { return undefined; } - const tokens = new RegExp(TEMPLATE_TOKEN_SOURCE, 'g'); - const templates = value.match(tokens)?.length ?? 0; - const text = value - .split(tokens) - .map((piece) => piece.trim()) - .filter((piece) => piece !== ''); - return { text, templates }; + const spans = templateTokenSpans(value); + const text: string[] = []; + let last = 0; + [...spans, [value.length, value.length] as [number, number]].forEach(([start, end]) => { + const piece = value.slice(last, start).trim(); + if (piece !== '') { + text.push(piece); + } + last = end; + }); + return { text, templates: spans.length }; }; /** A complete token carrying at least one BRACED context segment — From 0b6a6df726bd7e86674c15f8a3fbc330b95084b4 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 04:18:28 +0200 Subject: [PATCH 168/262] feat(dpql-editor): a field's chip takes the accessors after it: @create.items[*].id is one chip (qorus#646) The server writes a reference with a selector back as `@create.items[*].id`, and a field after an index as `@create.items[0]{"id"}`. The editor drew the chip `create.items` followed by the plain text `[*].id`. As David decided, the chip takes the whole accessor chain - `.name`, `[n]`, `[*]`, `{*}` and `{"key"}`, after a bare or a quoted field - labeled with the whole path (`create.items[*].id`, a quoted name unescaped) and written back exactly. A slice or an expression in brackets is not an accessor here, and ends the chip. Typed, a `[` after a field does not end it, so the chip is drawn once the character after the last accessor is typed. Unit: __tests__/dpqlEditor/accessorChains.test.ts (13 of 14 fail before; the one that passes is what is not an accessor). quotedReferences: a quoted field's accessor is now part of its chip. Stories: Components/DpqlEditor "With Accessor Chains" and "Accessor Chain Typed Out Is A Chip" (both fail before). design/SMART_EDITOR_UX.md says it. --- __tests__/dpqlEditor/accessorChains.test.ts | 68 +++++++++++++++++++ __tests__/dpqlEditor/quotedReferences.test.ts | 3 +- design/SMART_EDITOR_UX.md | 5 +- .../dpqlEditor/DpqlEditor.stories.tsx | 50 ++++++++++++++ src/components/dpqlEditor/dpqlHelpers.ts | 38 +++++++++-- 5 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 __tests__/dpqlEditor/accessorChains.test.ts diff --git a/__tests__/dpqlEditor/accessorChains.test.ts b/__tests__/dpqlEditor/accessorChains.test.ts new file mode 100644 index 00000000..0c75ca7b --- /dev/null +++ b/__tests__/dpqlEditor/accessorChains.test.ts @@ -0,0 +1,68 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A field with accessors is one chip (qorus#646, David): the server writes a reference with a selector back as +// `@create.items[*].id`, and the editor drew the chip `create.items` followed by the plain text `[*].id`. +import { + dpqlFieldName, + endsDpqlReference, + getTagLabel, + plainTextToSlate, + slateToPlainText, +} from '../../src/components/dpqlEditor/dpqlHelpers'; +import { ISlateElement } from '../../src/components/smartEditor/types'; + +const chipsIn = (text: string) => + plainTextToSlate(text).flatMap((paragraph) => + paragraph.children + .filter((node): node is ISlateElement => (node as ISlateElement).type === 'tag') + .map(({ value, label }) => ({ value, label })) + ); + +describe('a field with accessors is one chip', () => { + const chains: [string, string][] = [ + ['@create.items[*].id', 'create.items[*].id'], + ['@orders[*]', 'orders[*]'], + ['@prices{*}', 'prices{*}'], + ['@prices{*}.amount', 'prices{*}.amount'], + ['@items[0].id', 'items[0].id'], + ['@items[-1]', 'items[-1]'], + ['@create.items[0]{"id"}', 'create.items[0]{"id"}'], + ['@headers{"content-type"}', 'headers{"content-type"}'], + ['@orders[*].lines[*].sku', 'orders[*].lines[*].sku'], + ['@"order-lines"[*].id', 'order-lines[*].id'], + ]; + + test.each(chains)('%s, labeled %s, written back exactly', (text, label) => { + expect(chipsIn(text)).toEqual([{ value: text, label }]); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('in an expression: the chain ends where the accessors do', () => { + const text = 'size(@orders[*].id) + size(@prices{*}) > @items[0]'; + expect(chipsIn(text).map(({ label }) => label)).toEqual([ + 'orders[*].id', + 'prices{*}', + 'items[0]', + ]); + expect(slateToPlainText(plainTextToSlate(text))).toBe(text); + }); + + test('what is not an accessor is not part of it', () => { + // a slice, an expression in brackets, a hash literal + expect(chipsIn('@items[1..2]')).toEqual([{ value: '@items', label: 'items' }]); + expect(chipsIn('@items[@i]').map(({ value }) => value)).toEqual(['@items', '@i']); + expect(chipsIn('@a {"x": 1}')).toEqual([{ value: '@a', label: 'a' }]); + // an unclosed key + expect(chipsIn('@h{"x')).toEqual([{ value: '@h', label: 'h' }]); + }); + + test('its name and label, for the renderer and a completion', () => { + expect(dpqlFieldName('@create.items[*].id')).toBe('create.items[*].id'); + expect(dpqlFieldName('@"order-lines"[*].id')).toBe('order-lines[*].id'); + expect(getTagLabel('@"a\\"b"{*}')).toBe('a"b{*}'); + }); + + test('typed, an opening bracket does not end the field: an accessor may follow', () => { + expect(endsDpqlReference('[')).toBe(false); + expect(endsDpqlReference(' ')).toBe(true); + }); +}); diff --git a/__tests__/dpqlEditor/quotedReferences.test.ts b/__tests__/dpqlEditor/quotedReferences.test.ts index 0739e502..c400dc02 100644 --- a/__tests__/dpqlEditor/quotedReferences.test.ts +++ b/__tests__/dpqlEditor/quotedReferences.test.ts @@ -71,7 +71,8 @@ describe('a quoted field is drawn as its chip', () => { test('as an argument, and with an accessor after it', () => { expect(drawn('length(@"create.items")')).toBe('length([create.items])'); - expect(drawn('@"create.items"[0]')).toBe('[create.items][0]'); + // the accessor is part of it (accessorChains.test.ts) + expect(drawn('@"create.items"[0]')).toBe('[create.items[0]]'); }); test('an escaped quote does not end it', () => { diff --git a/design/SMART_EDITOR_UX.md b/design/SMART_EDITOR_UX.md index 75dc2861..a879ad89 100644 --- a/design/SMART_EDITOR_UX.md +++ b/design/SMART_EDITOR_UX.md @@ -183,7 +183,10 @@ reads, as it reads them (qorus#646): `$prefix:value`, `$prefix:{value}` (ending where the tokenizer's does: a backslash escapes the next character, braces nest, a string inside is read whole), `@field` and the quoted `@"field"` the server writes for a name DPQL does not read bare (its chip -labeled with the name, unescaped). A field inside a string literal is +labeled with the name, unescaped). A field's chip takes the accessors after +it, as the server writes a reference with a selector back: `.name`, `[n]`, +`[*]`, `{*}` and `{"key"}` (`@create.items[*].id` is one chip, +`create.items[*].id`, written back exactly). A field inside a string literal is text; a template in one is still drawn as its chip. It does not parse the expression around them. The server's `dpql/toRichtext` does that. diff --git a/src/components/dpqlEditor/DpqlEditor.stories.tsx b/src/components/dpqlEditor/DpqlEditor.stories.tsx index d1d2e946..76d46c6f 100644 --- a/src/components/dpqlEditor/DpqlEditor.stories.tsx +++ b/src/components/dpqlEditor/DpqlEditor.stories.tsx @@ -533,6 +533,56 @@ export const QuotedFieldTypedOutIsAChip: Story = { }, }; +/** + * A field with accessors (David, qorus#646): the server writes a reference with a selector back as + * `@create.items[*].id`, and a field after an index as `[0]{"id"}`. Each is one chip, labeled with the whole + * path, and written back as it was. + */ +export const WithAccessorChains: Story = { + args: { + value: 'size(@create.items[*].id) > size(@prices{*}) && @items[0]{"id"} != ""', + onChange: fn(), + }, + parameters: { + docs: { + description: { + story: + 'Renders the DpqlEditor with fields that carry accessors, as the server writes them: @create.items[*].id, @prices{*} and @items[0]{"id"}. Each is one chip labeled with its whole path - create.items[*].id, prices{*}, items[0]{"id"} - not a chip for the field and plain text for the accessors; the text is written back as it was.', + }, + }, + }, + play: async ({ canvasElement, args }) => { + const editable = within(canvasElement).getByRole('textbox'); + + await waitFor(() => + expect(chipLabels(editable)).toEqual(['create.items[*].id', 'prices{*}', 'items[0]{"id"}']) + ); + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + +export const AccessorChainTypedOutIsAChip: Story = { + args: { value: '', onChange: fn() }, + parameters: { + docs: { + description: { + story: + 'Renders the empty DpqlEditor and types @orders[*].id == 1 without picking from the list. When the space after it is typed, the field and its accessors are drawn as one chip, orders[*].id, and the rest is typed after it.', + }, + }, + }, + play: async ({ canvasElement, args }) => { + const editable = within(canvasElement).getByRole('textbox'); + + await userEvent.click(editable); + // `[` starts a key descriptor for userEvent; `[[` types one + await userEvent.type(editable, '@orders[[*].id == 1'); + + await waitFor(() => expect(chipLabels(editable)).toEqual(['orders[*].id'])); + await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('@orders[*].id == 1')); + }, +}; + /** * Exercises the `useServerParse` opt-in (design doc §6). The editor * calls `dpql/toRichtext` on the LSP and uses the server's structured diff --git a/src/components/dpqlEditor/dpqlHelpers.ts b/src/components/dpqlEditor/dpqlHelpers.ts index 7c0446cf..b2fe89d4 100644 --- a/src/components/dpqlEditor/dpqlHelpers.ts +++ b/src/components/dpqlEditor/dpqlHelpers.ts @@ -20,6 +20,29 @@ const NAME_PART = '[\\p{L}\\p{M}\\p{N}_]'; const FIELD_NAME = `${NAME_START}${NAME_PART}*`; /** A bare field, `@name.sub`, read where it starts. */ const BARE_FIELD = new RegExp(`@${FIELD_NAME}(?:\\.${FIELD_NAME})*`, 'uy'); +/** + * One accessor after a field, read where it starts: `.name`, `[n]`, `[*]`, `{*}` or `{"key"}`. A field with + * accessors is one reference, as the server writes one back (`@create.items[*].id`, `@items[0]{"id"}`): the chip + * was `create.items` and the accessors plain text after it (David, qorus#646). A slice or an expression in + * brackets is not one of them, and ends the chip. + */ +const ACCESSOR = new RegExp( + `\\.${FIELD_NAME}|\\[(?:-?\\d+|\\*)\\]|\\{\\*\\}|\\{"(?:\\\\.|[^"\\\\])*"\\}|\\{'(?:\\\\.|[^'\\\\])*'\\}`, + 'uy' +); + +/** Where the accessors after a field that ends at `from` end. */ +const endOfAccessors = (line: string, from: number): number => { + let end = from; + for (;;) { + ACCESSOR.lastIndex = end; + if (!ACCESSOR.test(line)) { + return end; + } + end = ACCESSOR.lastIndex; + } +}; + /** What a template starts with, `$prefix:`, read where it starts. */ const TEMPLATE_HEAD = /\$\w+:/y; // A template's value without braces is word characters and dots, not quotes, parens, etc. A dot that starts a @@ -123,6 +146,7 @@ const readField = (line: string, at: number): { end: number; tag: ISlateElement if (end < 0) { return undefined; } + end = endOfAccessors(line, end); const value = line.slice(at, end); return { end, tag: createTagElement(value, getTagLabel(value)) }; }; @@ -373,8 +397,12 @@ const unescapeDpqlString = (content: string): string => { * `order-id`, unescaped as the server reads it - or undefined when `value` is not a field reference. */ export const dpqlFieldName = (value: string): string | undefined => { - if (value.startsWith('@"') && value.length > 2 && value.endsWith('"')) { - return unescapeDpqlString(value.slice(2, -1)); + if (value.startsWith('@"')) { + // the quoted name, unescaped, and the accessors after it as they are written + const close = endOfQuoted(value, 2, '"'); + if (close > 0) { + return unescapeDpqlString(value.slice(2, close - 1)) + value.slice(close); + } } return value.startsWith('@') ? value.slice(1) : undefined; }; @@ -404,11 +432,11 @@ export function getTagLabel(value: string): string { /** * Whether a character ends the DPQL reference it follows: anything that cannot go on to make a field name or a - * template path longer - not a letter, a mark, a digit or `_`, nor a `.` (a sub-field) or a `:` / `{` (a - * template's path). + * template path longer - not a letter, a mark, a digit or `_`, nor a `.` (a sub-field), a `[` or a `{` (an accessor: + * `[*]`, `[0]`, `{*}`, `{"key"}`) or a `:` (a template's path). */ export const endsDpqlReference = (char: string): boolean => - !!char && !/^[\p{L}\p{M}\p{N}_.:{]$/u.test(char); + !!char && !/^[\p{L}\p{M}\p{N}_.:{[]$/u.test(char); /** DPQL converter — handed to `SmartEditor` via the `converter` prop. */ export const dpqlSlateConverter: ISlateConverter = { From a65477e75502000842fba3b742c93babe9b2cb1f Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 04:18:40 +0200 Subject: [PATCH 169/262] feat(expressions): a field path is drawn as the path; an operation flagged internal is never offered (qorus#646) The server reads a field path written with accessors - @orders[*].id, @prices{*}, @items[0].id, or the same inside $record:{...} - into accessor operations over record fields: list_wildcard, hash_wildcard, wildcard_item, index and key. The Visual tab drew each as an operation box, "list wildcard ([*])" with SOURCE `record: orders` and ITEM EXPRESSION `record: id`, and the `@value` of a hash wildcard - the item's value, not a record field - as "record: value". As David decided: - An expression made only of record fields and those operations is read as the path it is (fieldPath.ts: `orders[*].id`, `prices{*}`, `prices{*}.amount`, `items[0].id`, nested wildcards, a key that is not a name in braces). The Visual tab draws it as one display-only chip, the chip a record-field operand has (`record: orders[*].id`), alone or as an operand (`size(@orders[*].id) + size(@prices{*})`); it is edited on the Expression tab, and its operand menu replaces or removes it. Anything else in a wildcard's source or body keeps the operation box. - The row summary, the Text view's text of an unfinished expression and the server's rendering (Explain, the Preview, the read view) read it the same way: the server is asked to render each path as the reference it reads as, and a path that is the whole expression is that reference. What is stored is not changed. - A catalogue entry flagged `internal` - the server's flag for an operation drawn only inside the construct that makes it, wildcard_item - is left out of the operation list and the Wrap list (isOfferedForPicking). No rule by name: until the server sends the flag, the entry is offered as before. Unit: fieldPath (the reading) and fieldPathRendering (5 of 6 fail before; the one that passes is an expression with no path). Stories, each failing before: ExpressionBuilder "Wildcard Paths Are Drawn As Paths", "Wildcard Path Alone Is Its Path" and "Wildcard Item Is Never Offered", over a fixture of the server's wildcard catalogue entries (wildcardExpressions.ts) whose wildcard_item carries the flag. design/FORM_ENGINE_EXPRESSIONS.md says it. --- __tests__/fieldPath.test.ts | 115 ++++++++++++++ __tests__/fieldPathRendering.test.ts | 107 +++++++++++++ design/FORM_ENGINE_EXPRESSIONS.md | 4 + .../builder/ExpressionBuilder.stories.tsx | 108 +++++++++++++ .../form/expressions/builder/index.tsx | 24 +++ src/components/form/expressions/fieldPath.ts | 145 ++++++++++++++++++ .../expressions/renderExpressionToText.ts | 4 + .../form/expressions/textOfExpression.ts | 22 ++- src/components/form/expressions/types.ts | 9 ++ .../form/expressions/useRenderExpression.ts | 8 +- .../form/expressions/wildcardExpressions.ts | 105 +++++++++++++ .../fields/template/ReadOnlyTemplateTag.tsx | 11 +- 12 files changed, 654 insertions(+), 8 deletions(-) create mode 100644 __tests__/fieldPath.test.ts create mode 100644 __tests__/fieldPathRendering.test.ts create mode 100644 src/components/form/expressions/fieldPath.ts create mode 100644 src/components/form/expressions/wildcardExpressions.ts diff --git a/__tests__/fieldPath.test.ts b/__tests__/fieldPath.test.ts new file mode 100644 index 00000000..5759486b --- /dev/null +++ b/__tests__/fieldPath.test.ts @@ -0,0 +1,115 @@ +// Copyright 2026 Qore Technologies, s.r.o. +import { describe, expect, it } from 'vitest'; +import { fieldPathOf, fieldPathTemplate } from '../src/components/form/expressions/fieldPath'; +import { IExpression } from '../src/components/form/expressions/types'; + +/* + * A field path written with accessors is read as the path it is (qorus#646): the server reads `@orders[*].id` and + * `$record:{orders[*].id}` into accessor operations over record fields, and those are drawn as the path, not as an + * operation with a source and a body. + */ +const field = (name: string): IExpression => ({ type: 'auto', value: `$record:{${name}}` }); +const op = (exp: string, ...args: IExpression[]): IExpression => ({ + is_expression: true, + value: { exp, args }, +}); +const item: IExpression = op('wildcard_item'); + +describe('fieldPathOf', () => { + it('reads the paths the server writes as accessor operations', () => { + // @orders[*].id + expect(fieldPathOf(op('list_wildcard', field('orders'), field('id')))).toBe('orders[*].id'); + // @orders[*] + expect(fieldPathOf(op('list_wildcard', field('orders'), item))).toBe('orders[*]'); + // @prices{*}.amount: the body reads from the value + expect(fieldPathOf(op('hash_wildcard', field('prices'), field('value.amount')))).toBe( + 'prices{*}.amount' + ); + // @prices{*} + expect(fieldPathOf(op('hash_wildcard', field('prices'), field('value')))).toBe('prices{*}'); + // @items[0].id + expect( + fieldPathOf( + op('key', op('index', field('items'), { type: 'int', value: 0 }), { + type: 'string', + value: 'id', + }) + ) + ).toBe('items[0].id'); + // a dotted field + expect(fieldPathOf(op('list_wildcard', field('create.items'), field('id')))).toBe( + 'create.items[*].id' + ); + }); + + it('reads nested wildcards, and accessors on the item', () => { + // @orders[*].lines[*].sku + expect( + fieldPathOf( + op('list_wildcard', field('orders'), op('list_wildcard', field('lines'), field('sku'))) + ) + ).toBe('orders[*].lines[*].sku'); + // @matrix[*][0] + expect( + fieldPathOf( + op('list_wildcard', field('matrix'), op('index', item, { type: 'int', value: 0 })) + ) + ).toBe('matrix[*][0]'); + // @prices{*}.tiers[*].amount + expect( + fieldPathOf( + op( + 'hash_wildcard', + field('prices'), + op('list_wildcard', field('value.tiers'), field('amount')) + ) + ) + ).toBe('prices{*}.tiers[*].amount'); + }); + + it('writes a key that is not a name as the server does, in braces', () => { + expect( + fieldPathOf(op('key', field('headers'), { type: 'string', value: 'content-type' })) + ).toBe('headers{"content-type"}'); + }); + + it('takes the operation itself, or the operand that holds it', () => { + expect(fieldPathOf({ exp: 'list_wildcard', args: [field('orders'), field('id')] })).toBe( + 'orders[*].id' + ); + }); + + it('is no path for anything else: the operation is drawn as it is', () => { + // a field alone is the field + expect(fieldPathOf(field('orders'))).toBeUndefined(); + // an operation that is not an accessor + expect( + fieldPathOf(op('size', op('list_wildcard', field('orders'), field('id')))) + ).toBeUndefined(); + // a body that is not a path: a literal, a sum + expect( + fieldPathOf(op('list_wildcard', field('orders'), { type: 'int', value: 1 })) + ).toBeUndefined(); + expect( + fieldPathOf(op('list_wildcard', field('orders'), op('+', field('a'), field('b')))) + ).toBeUndefined(); + // a hash body that does not read from the value + expect(fieldPathOf(op('hash_wildcard', field('prices'), field('key')))).toBeUndefined(); + // the item outside a wildcard, a template that is not a record field, an index that is not a whole number + expect(fieldPathOf(item)).toBeUndefined(); + expect( + fieldPathOf(op('list_wildcard', { type: 'auto', value: '$local:{orders}' }, field('id'))) + ).toBeUndefined(); + expect( + fieldPathOf(op('index', field('items'), { type: 'auto', value: '$local:{i}' })) + ).toBeUndefined(); + expect(fieldPathOf(op('index', field('items'), { type: 'float', value: 1.5 }))).toBeUndefined(); + // a wildcard missing its body + expect(fieldPathOf(op('list_wildcard', field('orders')))).toBeUndefined(); + expect(fieldPathOf(undefined)).toBeUndefined(); + }); + + it('is drawn as the chip a record field is', () => { + expect(fieldPathTemplate('orders[*].id')).toBe('$record:{orders[*].id}'); + }); +}); diff --git a/__tests__/fieldPathRendering.test.ts b/__tests__/fieldPathRendering.test.ts new file mode 100644 index 00000000..84d3f520 --- /dev/null +++ b/__tests__/fieldPathRendering.test.ts @@ -0,0 +1,107 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A field path reads as the path wherever an expression is shown, as the Visual tab draws it (qorus#646, David): +// the row summary, the Text view's text of an unfinished expression, and the server's rendering (Explain, the +// Preview, the read view). +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const client = vi.hoisted(() => ({ + connect: vi.fn<() => Promise>(), + customRequest: vi.fn<() => Promise>(), + disconnect: vi.fn(), +})); + +vi.mock('../src/utils/lspClient', () => ({ + ReqraftLspClient: class { + constructor() { + return client; + } + }, +})); + +import { withFieldPathsAsReferences } from '../src/components/form/expressions/fieldPath'; +import { renderExpressionToText } from '../src/components/form/expressions/renderExpressionToText'; +import { incompleteExpressionText } from '../src/components/form/expressions/textOfExpression'; +import { IExpression, IExpressionSchema } from '../src/components/form/expressions/types'; +import { + _resetRenderExpressionTransportForTests, + useRenderExpression, +} from '../src/components/form/expressions/useRenderExpression'; + +const field = (name: string): IExpression => ({ type: 'auto', value: `$record:{${name}}` }); +const op = (exp: string, ...args: IExpression[]): IExpression => ({ + is_expression: true, + value: { exp, args }, +}); +const ORDER_IDS = op('list_wildcard', field('orders'), field('id')); +const PRICES = op('hash_wildcard', field('prices'), field('value')); +// size(@orders[*].id) + size(@prices{*}) +const SUM = { exp: '+', args: [op('size', ORDER_IDS), op('size', PRICES)] }; +const CATALOG = [ + { name: '+', symbol: '+', subtype: 1, args: [], varargs: true }, + { name: 'size', symbol: 'size', subtype: 1, args: [], varargs: false }, + { name: '>', symbol: '>', subtype: 1, args: [], varargs: false }, + { name: 'list_wildcard', symbol: '[*]', subtype: 1, args: [], varargs: false }, +] as unknown as IExpressionSchema[]; + +describe('the row summary', () => { + it('reads each path in an expression as the path', () => { + expect(renderExpressionToText(SUM, CATALOG)).toBe( + 'size("$record:{orders[*].id}") + size("$record:{prices{*}}")' + ); + }); + + it('reads an expression that is a path as the path', () => { + expect(renderExpressionToText(ORDER_IDS.value, CATALOG)).toBe('"$record:{orders[*].id}"'); + }); +}); + +describe("the Text view's text of an unfinished expression", () => { + it('names each path as DPQL does, braces and all', () => { + expect( + incompleteExpressionText({ exp: '>', args: [PRICES, undefined as never] }, CATALOG) + ).toBe('@prices{*} > …'); + expect(incompleteExpressionText({ exp: '>', args: [ORDER_IDS, field('limit')] }, CATALOG)).toBe( + '@orders[*].id > @limit' + ); + }); +}); + +describe("the server's rendering", () => { + beforeEach(() => { + _resetRenderExpressionTransportForTests(); + client.connect.mockReset().mockResolvedValue(undefined); + client.customRequest.mockReset().mockResolvedValue({ rendered: 'r', richtext: null }); + }); + afterEach(() => vi.useRealTimers()); + + it('is asked for the expression with each path as the reference it reads as', async () => { + const { result } = renderHook(() => useRenderExpression()); + await result.current.renderRich(SUM); + + expect(client.customRequest).toHaveBeenCalledWith('dpql/renderExpression', { + expression: { + exp: '+', + args: [ + op('size', { type: 'auto', value: '$record:{orders[*].id}' }), + op('size', { type: 'auto', value: '$record:{prices{*}}' }), + ], + }, + }); + }); + + it('is not asked for an expression that is a path: it is the reference', async () => { + const { result } = renderHook(() => useRenderExpression()); + + await expect(result.current.renderRich(PRICES.value)).resolves.toEqual({ + text: '$record:{prices{*}}', + richtext: null, + }); + expect(client.customRequest).not.toHaveBeenCalled(); + }); + + it('is asked for anything else as it is', () => { + const plain = { exp: '+', args: [field('a'), { type: 'int', value: 1 }] }; + expect(withFieldPathsAsReferences(plain)).toEqual(plain); + }); +}); diff --git a/design/FORM_ENGINE_EXPRESSIONS.md b/design/FORM_ENGINE_EXPRESSIONS.md index ccbe2e03..4a90d999 100644 --- a/design/FORM_ENGINE_EXPRESSIONS.md +++ b/design/FORM_ENGINE_EXPRESSIONS.md @@ -94,6 +94,10 @@ New subsystem `src/components/form/expressions/`: Touched: `TemplateField.tsx` (Use-Expression toggle + render `ExpressionField` when `is_expression`), `FormField`/`FormEngine` value plumbing for `is_expression`, the form barrel (exports). +**Field paths (qorus#646).** The server reads a field path written with accessors (`@orders[*].id`, `@prices{*}`, `@items[0].id`, or the same inside `$record:{…}`) into accessor operations: `list_wildcard`, `hash_wildcard`, `wildcard_item`, `index` and `key` over record fields. An expression made only of those is read as the path it is (`fieldPath.ts`). The Visual tab draws it as one display-only chip, the chip a record-field operand has (`record: orders[*].id`), not as an operation with a source and a body. It is edited on the Expression tab, and the operand's menu replaces or removes it. The row summary, the Text view's text of an unfinished expression, and the server's rendering (Explain, the Preview, the read view) read it the same way: the server is asked to render the path as the reference it reads as. What is stored is not changed. + +**Never offered for picking.** A catalogue entry flagged `internal` (`wildcard_item`, which DPQL does not write: it is drawn only inside the wildcard that makes it) is left out of the operation list and the Wrap list (`isOfferedForPicking`). The flag is the server's, with no rule by name in reqraft. + **Flow:** ``` FormEngine option { supports_expressions:true } diff --git a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx index 61424f43..ec7ce29c 100644 --- a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +++ b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx @@ -11,6 +11,7 @@ import { sleep } from '../../../../../__tests__/utils'; import { StoryMeta } from '../../../../types'; import { templateChipLabel } from '../../../../helpers/templateText'; import { mockExpressions } from '../mockExpressions'; +import { COUNTED_WILDCARDS, PRICES, WILDCARD_EXPRESSIONS } from '../wildcardExpressions'; import { IExpression, IExpressionSchema } from '../types'; import { ExpressionBuilder } from './index'; import { withCoarsePointer } from '../../../../stories/coarsePointer'; @@ -1962,3 +1963,110 @@ export const ConditionThatGivesANumberOnAPhone: Story = { }, }; + +// --- field paths written with accessors (qorus#646) --------------------------------------------------------- + +const WILDCARD_CATALOG = [...mockExpressions, ...WILDCARD_EXPRESSIONS] as IExpressionSchema[]; + +/** The field paths the builder draws, each as its chip's label. */ +const drawnFieldPaths = (scope: ParentNode) => + Array.from(scope.querySelectorAll('.expression-field-path')).map((path) => + (path.textContent ?? '').trim() + ); + +export const WildcardPathsAreDrawnAsPaths: Story = { + args: { + expressions: WILDCARD_CATALOG, + value: COUNTED_WILDCARDS as IExpression, + }, + parameters: { + docs: { + description: { + story: + 'Renders size(@orders[*].id) + size(@prices{*}) as the server parses it: each wildcard is an accessor operation over record fields. Each is drawn as the path it is - the chip "record: orders[*].id", the chip "record: prices{*}" - as a record field operand is, not as a "list wildcard" or "hash wildcard" operation whose operands read "record: id" and "record: value" (David, qorus#646).', + }, + }, + }, + play: async ({ canvasElement }) => { + await waitFor(() => + expect(drawnFieldPaths(canvasElement)).toEqual(['record: orders[*].id', 'record: prices{*}']) + ); + // the two `size` operations and the sum are drawn as operations, the paths in them are not + expect(expressionCount(canvasElement)).toBe(3); + expect(canvasElement.textContent).not.toMatch(/list wildcard|hash wildcard|record: value/); + // each is an operand as any is: its menu replaces it, or removes it + for (const path of Array.from(canvasElement.querySelectorAll('.expression-field-path'))) { + expect(path.closest('.reqore-control-group')?.querySelector('.template-more')).toBeTruthy(); + } + }, +}; + +export const WildcardPathAloneIsItsPath: Story = { + args: { + expressions: WILDCARD_CATALOG, + value: PRICES as IExpression, + }, + parameters: { + docs: { + description: { + story: + 'Renders @prices{*} - the values of a hash - as the whole value: it is drawn as the path, the chip "record: prices{*}", with no operation to choose. It is edited on the Expression tab.', + }, + }, + }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(drawnFieldPaths(canvasElement)).toEqual(['record: prices{*}'])); + expect(expressionCount(canvasElement)).toBe(0); + expect(canvasElement.querySelector('#expression-builder-select-function')).toBeNull(); + }, +}; + +/** The entries the open operation list offers, by name. */ +const offeredOperations = async (trigger: string) => { + const body = within(document.body); + await fireEvent.click(await body.findByText(trigger, undefined, { timeout: 10000 })); + return waitFor( + () => { + const offered = body.queryAllByText('list wildcard ([*])'); + if (!offered.length) throw new Error('the operation list is not open'); + return document.body.textContent ?? ''; + }, + { timeout: 10000 } + ); +}; + +export const WildcardItemIsNeverOffered: Story = { + args: { + expressions: WILDCARD_CATALOG, + value: { + is_expression: true, + value: { exp: 'size', args: [{ type: 'auto', value: '$record:{orders}' }] }, + } as IExpression, + }, + parameters: { + docs: { + description: { + story: + 'Renders size(@orders) with a catalogue in which "wildcard item" - the item of a [*] wildcard, which DPQL does not write - carries the server\'s flag for an operation never offered for picking. Neither the operation list nor the Wrap list offers it; "list wildcard ([*])" and "hash wildcard ({*})" are offered (qorus#646).', + }, + }, + }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(expressionCount(canvasElement)).toBe(1), { timeout: 10000 }); + + // the operation list + const operations = await offeredOperations('Size'); + expect(operations).toContain('hash wildcard ({*})'); + expect(operations).not.toContain('wildcard item'); + await userEvent.keyboard('{Escape}'); + await waitFor(() => + expect(within(document.body).queryAllByText('list wildcard ([*])')).toHaveLength(0) + ); + + // the Wrap list: the same entries + await userEvent.hover(canvasElement.querySelectorAll('.expression')[0]); + const wraps = await offeredOperations('Wrap'); + expect(wraps).toContain('hash wildcard ({*})'); + expect(wraps).not.toContain('wildcard item'); + }, +}; diff --git a/src/components/form/expressions/builder/index.tsx b/src/components/form/expressions/builder/index.tsx index a2afa65e..200cf1fb 100644 --- a/src/components/form/expressions/builder/index.tsx +++ b/src/components/form/expressions/builder/index.tsx @@ -40,8 +40,10 @@ import { isTextOrDateType, } from '../../../../helpers/expressions'; import { addMissingExpressionArgs } from '../argumentPresence'; +import { fieldPathOf, fieldPathTemplate } from '../fieldPath'; import { isUntypedOptionType } from '../../../../helpers/optionUiTypes'; import { findTemplate } from '../../../../helpers/templates'; +import { templateChipLabel } from '../../../../helpers/templateText'; import { validateField, validateFieldWithResult } from '../../../../helpers/validations'; import { IQorusTypeObject, useQorusTypes } from '../../../../hooks/useQorusTypes'; import { useReqraftStorage } from '../../../../hooks/useStorage/useStorage'; @@ -50,6 +52,7 @@ import { useCoarsePointer } from '../../../../hooks/useCoarsePointer'; import { useTemplates } from '../../../../hooks/useTemplates'; import { AutoFormField as auto } from '../../fields/auto/AutoFormField'; import { SelectFormField as Select } from '../../fields/select/Select'; +import { ReadOnlyTemplateTag } from '../../fields/template/ReadOnlyTemplateTag'; import { ITemplateMenuActions, TCustomTemplateItems, @@ -60,6 +63,7 @@ import { IExpression, IExpressionSchema, IExpressionSchemaArg, + isOfferedForPicking, TExpressionReorder, TExpressionReorderSurface, } from '../types'; @@ -658,6 +662,8 @@ export const Expression = ({ const defaultItems = useMemo( () => expressions.value + // an operation drawn only inside the construct that makes it is never offered (`internal`) + .filter(isOfferedForPicking) // a condition (a field or an operand that must be true or false) offers the operations whose // result decides it - any but text, a date or a time, which are compared explicitly; a group's members // are its own business, and the operation already chosen @@ -1650,6 +1656,24 @@ export const ExpressionBuilder = ({ ); } + /* A field path written with accessors - `orders[*].id`, `prices{*}` - is drawn as the path, the chip a record + field is, not as the accessor operations it is made of: their operands misread (the `@value` of a hash + wildcard is the item's value, not a record field), and an operation with a source and a body is not what + was written (David, qorus#646). It is edited on the Expression tab; here it can be removed, as any operand + can, with its field's controls. */ + const fieldPath = fieldPathOf(value); + if (fieldPath !== undefined) { + return ( + + + + ); + } + return ( + arg?.is_expression && arg.value && typeof arg.value === 'object' ? arg.value : undefined; + +/** The record field an operand names, when it is a field: `$record:{orders}` is `orders`. */ +const recordFieldOf = (arg?: IExpression): string | undefined => { + if (!arg || arg.is_expression || typeof arg.value !== 'string') { + return undefined; + } + return recordFieldOfTemplate(arg.value); +}; + +/** + * The path an operand is, or undefined when it is anything else. `relative`: it is a wildcard's body, read from the + * item (`wildcard_item`, which is nothing written) - a field there is written after a dot (`[*].id`). + */ +const pathOfOperand = (arg: IExpression | undefined, relative: boolean): string | undefined => { + const field = recordFieldOf(arg); + if (field !== undefined) { + return ( + field === '' ? undefined + : relative ? `.${field}` + : field + ); + } + const operation = operationOf(arg); + return operation ? pathOfOperation(operation, relative) : undefined; +}; + +/** A hash wildcard's body: read from the value (`@value`), which is not written - `{*}.amount`, `{*}`. */ +const hashBodyOf = (arg?: IExpression): string | undefined => { + const body = pathOfOperand(arg, true); + if (body === undefined) { + return undefined; + } + if (body === '.value') { + return ''; + } + return /^\.value[.[{]/.test(body) ? body.slice('.value'.length) : undefined; +}; + +/** A literal operand's value: what `index` and `key` take. */ +const literalOf = (arg?: IExpression): unknown => + arg && !arg.is_expression && !(typeof arg.value === 'string' && arg.value.startsWith('$')) ? + arg.value + : undefined; + +const pathOfOperation = (operation: IExpressionValue, relative: boolean): string | undefined => { + const [source, second] = operation.args ?? []; + const count = operation.args?.length ?? 0; + + switch (operation.exp) { + case FIELD_PATH_OPERATIONS.wildcardItem: + // the item of the enclosing [*]: nothing written, and only inside a wildcard + return relative && count === 0 ? '' : undefined; + case FIELD_PATH_OPERATIONS.listWildcard: + case FIELD_PATH_OPERATIONS.hashWildcard: { + if (count !== 2) { + return undefined; + } + const from = pathOfOperand(source, relative); + const isList = operation.exp === FIELD_PATH_OPERATIONS.listWildcard; + const body = isList ? pathOfOperand(second, true) : hashBodyOf(second); + return from === undefined || body === undefined ? + undefined + : `${from}${isList ? '[*]' : '{*}'}${body}`; + } + case FIELD_PATH_OPERATIONS.index: { + const from = pathOfOperand(source, relative); + const at = literalOf(second); + return count === 2 && from !== undefined && typeof at === 'number' && Number.isInteger(at) ? + `${from}[${at}]` + : undefined; + } + case FIELD_PATH_OPERATIONS.key: { + const from = pathOfOperand(source, relative); + const key = literalOf(second); + if (count !== 2 || from === undefined || typeof key !== 'string') { + return undefined; + } + return BARE_KEY.test(key) ? `${from}.${key}` : `${from}{${JSON.stringify(key)}}`; + } + default: + return undefined; + } +}; + +/** + * The field path an expression is, as written - `orders[*].id`, `prices{*}`, `items[0].id` - when it is made only of + * record fields and the accessor operations over them (and holds at least one); otherwise undefined, and it is + * drawn as the operation it is. A record field alone is not a path: it is the field. + */ +export const fieldPathOf = (expression?: IExpression | IExpressionValue): string | undefined => { + const operation = + expression && 'is_expression' in expression ? + operationOf(expression as IExpression) + : (expression as IExpressionValue); + if (!operation?.exp) { + return undefined; + } + const path = pathOfOperation(operation, false); + return path === '' ? undefined : path; +}; + +/** The template a field path is drawn as: the same chip a record field operand is, `$record:{orders[*].id}`. */ +export const fieldPathTemplate = (path: string): string => `$record:{${path}}`; + +/** + * The expression with each field path in it written as the reference it reads as - `$record:{orders[*].id}` - for + * a rendering to draw it as the Visual tab does: the path's chip, not the accessor operations. What is stored is + * not changed; this is only what is shown. + */ +export const withFieldPathsAsReferences = (value: IExpressionValue): IExpressionValue => ({ + ...value, + args: value.args?.map((arg) => { + const path = arg?.is_expression ? fieldPathOf(arg) : undefined; + if (path !== undefined) { + return { type: 'auto', value: fieldPathTemplate(path) }; + } + return arg?.is_expression && arg.value?.exp ? + { ...arg, value: withFieldPathsAsReferences(arg.value) } + : arg; + }), +}); diff --git a/src/components/form/expressions/renderExpressionToText.ts b/src/components/form/expressions/renderExpressionToText.ts index 9c9b1acb..49041364 100644 --- a/src/components/form/expressions/renderExpressionToText.ts +++ b/src/components/form/expressions/renderExpressionToText.ts @@ -5,6 +5,7 @@ // `dpql/renderExpression` (see `useRenderExpression`). Pure: no // transport/socket import, so it stays out of the LSP/nanoid dependency graph. import { expressionOperand } from './expressionOperands'; +import { fieldPathOf, fieldPathTemplate } from './fieldPath'; import { IExpression, IExpressionSchema, IExpressionValue } from './types'; /** How a part not filled in yet is written: an operation not chosen, a value not given. */ @@ -96,6 +97,9 @@ const renderInContext = ( contextPrec: number ): string => { if (!value?.exp) return ''; + // a field path reads as the path, the reference a record field operand is (fieldPath, qorus#646) + const path = fieldPathOf(value); + if (path !== undefined) return renderLiteral(fieldPathTemplate(path)); /* A `value` expression IS its literal. It is the wrapper the parser puts around a bare literal — typing `null`, diff --git a/src/components/form/expressions/textOfExpression.ts b/src/components/form/expressions/textOfExpression.ts index c1a782a8..8f46f786 100644 --- a/src/components/form/expressions/textOfExpression.ts +++ b/src/components/form/expressions/textOfExpression.ts @@ -6,6 +6,7 @@ // `{args=(null)} > {type=int}`, a stand-in `undefined([object Object]) > [object Object]`. // The Text view serializes only an expression that is filled in, with its operands // read as what they hold, and shows one that is not with a hole for each missing part. +import { recordFieldOfTemplate, templateTokenSpans } from '../../../helpers/templates'; import { expressionOperand, expressionWithOperands, isExpressionComplete } from './expressionOperands'; import { EXPRESSION_HOLE, renderExpressionToText } from './renderExpressionToText'; import { IExpressionSchema, IExpressionValue } from './types'; @@ -30,10 +31,21 @@ export const incompleteExpressionText = ( value: IExpressionValue | undefined, expressions: IExpressionSchema[] = [] ): string => - value?.exp - ? renderExpressionToText(value, expressions) - // a field of the record, as DPQL names it - .replace(/"\$record:\{([^}]+)\}"/g, '@$1') - : EXPRESSION_HOLE; + value?.exp ? recordFieldsAsDpql(renderExpressionToText(value, expressions)) : EXPRESSION_HOLE; + +/** Each quoted record field in `text` as DPQL names it: `"$record:{orders[*].id}"` is `@orders[*].id`. Read as + * templates are, so a path with braces in it (`prices{*}`) is one field, not cut at its first `}`. */ +const recordFieldsAsDpql = (text: string): string => { + let out = ''; + let last = 0; + templateTokenSpans(text).forEach(([start, end]) => { + const field = recordFieldOfTemplate(text.slice(start, end)); + if (field && text[start - 1] === '"' && text[end] === '"') { + out += `${text.slice(last, start - 1)}@${field}`; + last = end + 1; + } + }); + return out + text.slice(last); +}; export { expressionOperand }; diff --git a/src/components/form/expressions/types.ts b/src/components/form/expressions/types.ts index 0587af08..b08f753a 100644 --- a/src/components/form/expressions/types.ts +++ b/src/components/form/expressions/types.ts @@ -97,8 +97,17 @@ export interface IExpressionSchema { from_both?: boolean; return_type_first_arg?: boolean; return_type_arg_priority?: string[]; + /** + * Never offered for picking: the operation is drawn only inside the construct that makes it - `wildcard_item`, + * the item of a `[*]` wildcard, which DPQL does not write (qorus#646). The operation pickers leave it out. + */ + internal?: boolean; } +/** Whether a catalogue entry is offered in the operation pickers: any not flagged `internal`. */ +export const isOfferedForPicking = (schema: Pick): boolean => + schema.internal !== true; + /** Where a varargs expression offers operand reordering. */ export type TExpressionReorderSurface = 'overflowMenu' | 'dragHandle' | 'positionPicker'; diff --git a/src/components/form/expressions/useRenderExpression.ts b/src/components/form/expressions/useRenderExpression.ts index d0ff8f85..229a71d7 100644 --- a/src/components/form/expressions/useRenderExpression.ts +++ b/src/components/form/expressions/useRenderExpression.ts @@ -21,6 +21,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { ReqraftLspClient } from '../../../utils/lspClient'; import { IExpressionValue } from './types'; import { expressionWithOperands, isExpressionComplete } from './expressionOperands'; +import { fieldPathOf, fieldPathTemplate, withFieldPathsAsReferences } from './fieldPath'; /* The synchronous summary a collapsed row prints — see `readFirst.ts`. It is not a stand-in for this rendering and is never shown in its place. */ @@ -57,6 +58,11 @@ export interface IRenderedExpression { } const renderViaServer = async (value: IExpressionValue): Promise => { + /* A field path (`orders[*].id`) reads as the path, as the Visual tab draws it, not as the accessor operations + it is made of (qorus#646): one that is the whole expression is its reference, and one in it is rendered as + the reference it reads as. */ + const path = fieldPathOf(value); + if (path !== undefined) return { text: fieldPathTemplate(path), richtext: null }; if (methodUnsupported) return null; const client = getRenderClient(); try { @@ -67,7 +73,7 @@ const renderViaServer = async (value: IExpressionValue): Promise ({ + name: 'any', + display_name, + short_desc, + ui_type: 'any', + required: true, +}); + +export const WILDCARD_EXPRESSIONS: IExpressionSchema[] = [ + { + name: 'size', + display_name: 'Size', + short_desc: 'Returns the number of elements in a list or hash', + desc: 'Returns the number of elements in a list, the number of keys in a hash, or the length of a string', + symbol: 'size', + type: 2, + subtype: 1, + role: 3, + return_type: 'int', + ui_return_type: 'int', + varargs: false, + groups: ['List'], + args: [anyArg('Value', 'The list, hash or string to measure')], + }, + { + name: 'list_wildcard', + display_name: 'list wildcard ([*])', + short_desc: 'Applies the accessors after [*] to each item of a list, returning a new list', + desc: 'Written `source[*]` followed by accessors, such as `@orders[*].id`', + symbol: '[*]', + render_template: '$arg[0][*] $arg[1]', + type: 1, + subtype: 1, + role: 3, + return_type: 'list', + ui_return_type: 'list', + varargs: false, + groups: ['Data Manipulation'], + args: [ + anyArg('Source', 'The list to iterate over'), + anyArg('Item Expression', 'The accessors evaluated for each item'), + ], + }, + { + name: 'hash_wildcard', + display_name: 'hash wildcard ({*})', + short_desc: 'Applies the accessors after {*} to each value of a hash, returning a new list', + desc: 'Written `source{*}` followed by accessors, such as `@prices{*}.amount`', + symbol: '{*}', + render_template: '$arg[0]{*} $arg[1]', + type: 1, + subtype: 1, + role: 3, + return_type: 'list', + ui_return_type: 'list', + varargs: false, + groups: ['Data Manipulation'], + args: [ + anyArg('Source', 'The hash to iterate over'), + anyArg('Value Expression', 'The accessors evaluated for each value'), + ], + }, + { + name: 'wildcard_item', + display_name: 'wildcard item', + short_desc: 'The item of the enclosing [*] wildcard', + desc: + 'The item that the accessors after `[*]` are applied to when they do not start with a field; it is only ' + + 'valid in a list wildcard and is not written in DPQL', + symbol: '', + type: 1, + subtype: 1, + role: 3, + return_type: 'any', + ui_return_type: 'any', + varargs: false, + groups: ['Data Manipulation'], + args: [], + // the server's flag: drawn only inside a wildcard, never offered for picking + internal: true, + }, +]; + +const field = (name: string) => ({ type: 'auto', value: `$record:{${name}}` }); +const operation = (exp: string, ...args: unknown[]) => ({ + is_expression: true, + value: { exp, args }, +}); + +/** `@orders[*].id`, as the server parses it. */ +export const ORDER_IDS = operation('list_wildcard', field('orders'), field('id')); +/** `@prices{*}`, as the server parses it. */ +export const PRICES = operation('hash_wildcard', field('prices'), field('value')); +/** `size(@orders[*].id) + size(@prices{*})`. */ +export const COUNTED_WILDCARDS = operation( + '+', + operation('size', ORDER_IDS), + operation('size', PRICES) +); diff --git a/src/components/form/fields/template/ReadOnlyTemplateTag.tsx b/src/components/form/fields/template/ReadOnlyTemplateTag.tsx index a7ee4712..55bf08e4 100644 --- a/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +++ b/src/components/form/fields/template/ReadOnlyTemplateTag.tsx @@ -15,6 +15,12 @@ export interface IReadOnlyTemplateTagProps { value: string; /** Template catalogue, for resolving the display name + image + metadata. */ templates?: IReqoreFormTemplates; + /** + * What the chip says, where the surface names a reference its own way: in the Visual tab an operand's chip + * says `record: orders` (`templateChipLabel`), and a field path drawn among them says it the same way. By + * default, the name the catalogue gives the reference, else its path. + */ + label?: string; } /** @@ -29,8 +35,9 @@ export interface IReadOnlyTemplateTagProps { * it's shown non-editably. */ export const ReadOnlyTemplateTag = memo( - ({ value, templates }: IReadOnlyTemplateTagProps) => { - const { label, item } = describeTemplateReference(templates, value); + ({ value, templates, label: givenLabel }: IReadOnlyTemplateTagProps) => { + const { label: described, item } = describeTemplateReference(templates, value); + const label = givenLabel ?? described; const metadata = item?.metadata as TTemplateMeta | undefined; const descriptions = useDescriptionTooltips(); return ( From 3986382a367d9d6be6d90bd44b61646cde012275 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 04:20:08 +0200 Subject: [PATCH 170/262] fix(stories): a story's description is in American English: catalog (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a65477e7 wrote "catalogue" in the description of ExpressionBuilder · Wildcard Item Is Never Offered, which americanSpelling.test.ts refuses. --- .../form/expressions/builder/ExpressionBuilder.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx index ec7ce29c..194911cb 100644 --- a/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +++ b/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx @@ -2047,7 +2047,7 @@ export const WildcardItemIsNeverOffered: Story = { docs: { description: { story: - 'Renders size(@orders) with a catalogue in which "wildcard item" - the item of a [*] wildcard, which DPQL does not write - carries the server\'s flag for an operation never offered for picking. Neither the operation list nor the Wrap list offers it; "list wildcard ([*])" and "hash wildcard ({*})" are offered (qorus#646).', + 'Renders size(@orders) with a catalog in which "wildcard item" - the item of a [*] wildcard, which DPQL does not write - carries the server\'s flag for an operation never offered for picking. Neither the operation list nor the Wrap list offers it; "list wildcard ([*])" and "hash wildcard ({*})" are offered (qorus#646).', }, }, }, From a549da3532a50a9869184803bd103ef063cb74b3 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 04:36:26 +0200 Subject: [PATCH 171/262] test(stories): a template whose braced value holds braces is one chip in a read text (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components/Form/TemplateText · Braced Templates Are Whole: $record:{prices{*}} and $record:{h.\\{*}} (a key named {*}, as the PO writes it) are each one chip, and the text after them stays text. With the braced value ended at its first } again (the rule before 6211b79b), the chips are "record: prices{*" and the story fails. --- .../fields/template/TemplateText.stories.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/components/form/fields/template/TemplateText.stories.tsx b/src/components/form/fields/template/TemplateText.stories.tsx index 113916c4..09d7cdb2 100644 --- a/src/components/form/fields/template/TemplateText.stories.tsx +++ b/src/components/form/fields/template/TemplateText.stories.tsx @@ -1,5 +1,6 @@ // Copyright 2026 Qore Technologies, s.r.o. import { StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; import { templateTextSegments } from '../../../../helpers/templateText'; import { @@ -71,3 +72,29 @@ export const WithoutAHostRenderer: Story = { }, play: playHoverIsPlainWithoutAHost, }; + +/** The chips a read text draws, each as its words. */ +const chipsIn = (scope: ParentNode) => + Array.from(scope.querySelectorAll('.reqraft-template-chip')).map((chip) => + (chip.textContent ?? '').trim() + ); + +export const BracedTemplatesAreWhole: Story = { + args: { + segments: templateTextSegments('Prices: $record:{prices{*}}, keys: $record:{h.\\\\{*}} kg'), + templates: undefined, + }, + parameters: { + docs: { + description: { + story: + 'Renders text with two templates whose braced values hold braces: $record:{prices{*}} - the values of a hash, {*} written unescaped - and $record:{h.\\\\{*}}, a key named {*}. Each is one chip, "record: prices{*}" and "record: h.\\\\{*}", and the text after them stays text; the value ended at its first } before, a chip "record: prices{*" and a stray "}" (qorus#646).', + }, + }, + }, + play: async ({ canvasElement }) => { + await expect(chipsIn(canvasElement)).toEqual(['record: prices{*}', 'record: h.\\\\{*}']); + await expect(canvasElement.textContent).toContain('kg'); + await expect(canvasElement.textContent).not.toMatch(/\{\*\s*\}\s*\}/); + }, +}; From 68b3c8ba1faef713c4f47ea584e63e0792be6448 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 12:53:27 +0200 Subject: [PATCH 172/262] fix(dpql-editor): a chip takes the accessors as the server now writes them: .name after an index, {b} after a field (qorus#646) Qore f341ddfc0 writes a key that is a name after an accessor as `.name` (`@create.items[0].id`, which came back as `[0]{"id"}` before), keeps braces for a key that needs quoting (`{"a b"}`, `{"true"}`) and for a key directly after a field (`@a{b}`, since `@a.b` is the field `a.b`), and writes keys in braces unquoted when they are bare (`{b}`, `{a, "b c"}`). The chip took only quoted keys in braces and a name after a dot without dashes: it stopped before `{b}` and before the `-id` of `.order-id`, which the tokenizer reads as one name after a dot. It now takes those as the server writes them. A field path drawn in the Visual tab is the test path `$record:{...}` holds: a key is written after a dot there (`headers.content-type`), and a key with a dot, a bracket, a brace or a backslash in it - which a test path would read as part of the path - is not drawn as a path (it keeps its operation box), instead of the DPQL `{"key"}` the path was given before. Changed: accessorChains.test.ts (the server's new forms; the old `[0]{"id"}` is still read), fieldPath.test.ts (a key as a test path writes it), and the story Components/DpqlEditor "With Accessor Chains", which now shows `@items[0].id` and `@headers{"content-type"}` as the server writes them. --- __tests__/dpqlEditor/accessorChains.test.ts | 10 +++++++++- __tests__/fieldPath.test.ts | 13 +++++++++++-- src/components/dpqlEditor/DpqlEditor.stories.tsx | 16 +++++++++++----- src/components/dpqlEditor/dpqlHelpers.ts | 14 +++++++++----- src/components/form/expressions/fieldPath.ts | 9 ++++++--- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/__tests__/dpqlEditor/accessorChains.test.ts b/__tests__/dpqlEditor/accessorChains.test.ts index 0c75ca7b..17759830 100644 --- a/__tests__/dpqlEditor/accessorChains.test.ts +++ b/__tests__/dpqlEditor/accessorChains.test.ts @@ -25,8 +25,16 @@ describe('a field with accessors is one chip', () => { ['@prices{*}.amount', 'prices{*}.amount'], ['@items[0].id', 'items[0].id'], ['@items[-1]', 'items[-1]'], - ['@create.items[0]{"id"}', 'create.items[0]{"id"}'], + // a key that is a name after an accessor, as the server writes it now (Qore f341ddfc0) + ['@create.items[0].id', 'create.items[0].id'], + ['@items[0].order-id', 'items[0].order-id'], + // a key that needs quoting keeps its braces, and so does a key directly after a field + ['@create.items[0]{"a b"}', 'create.items[0]{"a b"}'], ['@headers{"content-type"}', 'headers{"content-type"}'], + ['@a{b}', 'a{b}'], + ['@h{a, "b c"}', 'h{a, "b c"}'], + // as the server wrote a key after an index before + ['@create.items[0]{"id"}', 'create.items[0]{"id"}'], ['@orders[*].lines[*].sku', 'orders[*].lines[*].sku'], ['@"order-lines"[*].id', 'order-lines[*].id'], ]; diff --git a/__tests__/fieldPath.test.ts b/__tests__/fieldPath.test.ts index 5759486b..d935aecb 100644 --- a/__tests__/fieldPath.test.ts +++ b/__tests__/fieldPath.test.ts @@ -67,10 +67,19 @@ describe('fieldPathOf', () => { ).toBe('prices{*}.tiers[*].amount'); }); - it('writes a key that is not a name as the server does, in braces', () => { + it('writes a key as a test path does, after a dot, and is no path for a key a test path would misread', () => { + // the path is what `$record:{…}` holds: a test path expect( fieldPathOf(op('key', field('headers'), { type: 'string', value: 'content-type' })) - ).toBe('headers{"content-type"}'); + ).toBe('headers.content-type'); + expect(fieldPathOf(op('key', field('headers'), { type: 'string', value: 'a b' }))).toBe( + 'headers.a b' + ); + for (const key of ['a.b', 'a[0]', 'a{b}', 'a\\b', '']) { + expect( + fieldPathOf(op('key', field('headers'), { type: 'string', value: key })) + ).toBeUndefined(); + } }); it('takes the operation itself, or the operand that holds it', () => { diff --git a/src/components/dpqlEditor/DpqlEditor.stories.tsx b/src/components/dpqlEditor/DpqlEditor.stories.tsx index 76d46c6f..d1816aa3 100644 --- a/src/components/dpqlEditor/DpqlEditor.stories.tsx +++ b/src/components/dpqlEditor/DpqlEditor.stories.tsx @@ -535,19 +535,20 @@ export const QuotedFieldTypedOutIsAChip: Story = { /** * A field with accessors (David, qorus#646): the server writes a reference with a selector back as - * `@create.items[*].id`, and a field after an index as `[0]{"id"}`. Each is one chip, labeled with the whole - * path, and written back as it was. + * `@create.items[*].id`, a key after an index as `.id`, and a key that needs quoting in braces. Each is one chip, + * labeled with the whole path, and written back as it was. */ export const WithAccessorChains: Story = { args: { - value: 'size(@create.items[*].id) > size(@prices{*}) && @items[0]{"id"} != ""', + value: + 'size(@create.items[*].id) > size(@prices{*}) && @items[0].id != @headers{"content-type"}', onChange: fn(), }, parameters: { docs: { description: { story: - 'Renders the DpqlEditor with fields that carry accessors, as the server writes them: @create.items[*].id, @prices{*} and @items[0]{"id"}. Each is one chip labeled with its whole path - create.items[*].id, prices{*}, items[0]{"id"} - not a chip for the field and plain text for the accessors; the text is written back as it was.', + 'Renders the DpqlEditor with fields that carry accessors, as the server writes them: @create.items[*].id, @prices{*}, @items[0].id and @headers{"content-type"}. Each is one chip labeled with its whole path - create.items[*].id, prices{*}, items[0].id, headers{"content-type"} - not a chip for the field and plain text for the accessors; the text is written back as it was.', }, }, }, @@ -555,7 +556,12 @@ export const WithAccessorChains: Story = { const editable = within(canvasElement).getByRole('textbox'); await waitFor(() => - expect(chipLabels(editable)).toEqual(['create.items[*].id', 'prices{*}', 'items[0]{"id"}']) + expect(chipLabels(editable)).toEqual([ + 'create.items[*].id', + 'prices{*}', + 'items[0].id', + 'headers{"content-type"}', + ]) ); await expect(args.onChange).not.toHaveBeenCalled(); }, diff --git a/src/components/dpqlEditor/dpqlHelpers.ts b/src/components/dpqlEditor/dpqlHelpers.ts index b2fe89d4..f30b5563 100644 --- a/src/components/dpqlEditor/dpqlHelpers.ts +++ b/src/components/dpqlEditor/dpqlHelpers.ts @@ -21,13 +21,17 @@ const FIELD_NAME = `${NAME_START}${NAME_PART}*`; /** A bare field, `@name.sub`, read where it starts. */ const BARE_FIELD = new RegExp(`@${FIELD_NAME}(?:\\.${FIELD_NAME})*`, 'uy'); /** - * One accessor after a field, read where it starts: `.name`, `[n]`, `[*]`, `{*}` or `{"key"}`. A field with - * accessors is one reference, as the server writes one back (`@create.items[*].id`, `@items[0]{"id"}`): the chip - * was `create.items` and the accessors plain text after it (David, qorus#646). A slice or an expression in - * brackets is not one of them, and ends the chip. + * One accessor after a field, read where it starts, as the server writes it back (Qore DpqlSerializer): `.name`, + * `[n]`, `[*]`, `{*}`, or keys in braces - `{"id"}`, `{b}` (a key after a field: `@a.b` is the field `a.b`), + * `{a, "b c"}`. A field with accessors is one reference (`@create.items[*].id`, `@create.items[0].id`): the chip was + * `create.items` and the accessors plain text after it (David, qorus#646). A name after a dot may hold a dash before + * a letter (`.order-id`), as the tokenizer reads one. A slice or an expression in brackets is not one of them, and + * ends the chip. */ +const MEMBER_NAME = `${NAME_START}(?:${NAME_PART}|-[a-zA-Z])*`; +const KEY = `(?:[A-Za-z_]\\w*|"(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*')`; const ACCESSOR = new RegExp( - `\\.${FIELD_NAME}|\\[(?:-?\\d+|\\*)\\]|\\{\\*\\}|\\{"(?:\\\\.|[^"\\\\])*"\\}|\\{'(?:\\\\.|[^'\\\\])*'\\}`, + `\\.${MEMBER_NAME}|\\[(?:-?\\d+|\\*)\\]|\\{\\*\\}|\\{\\s*${KEY}(?:\\s*,\\s*${KEY})*\\s*\\}`, 'uy' ); diff --git a/src/components/form/expressions/fieldPath.ts b/src/components/form/expressions/fieldPath.ts index bb3ef50f..f917455b 100644 --- a/src/components/form/expressions/fieldPath.ts +++ b/src/components/form/expressions/fieldPath.ts @@ -16,8 +16,11 @@ export const FIELD_PATH_OPERATIONS = { key: 'key', } as const; -/** A key that reads as a field name after a dot; any other is written `{"key"}`. */ -const BARE_KEY = /^[\p{L}_][\p{L}\p{M}\p{N}_]*$/u; +/** + * A key a path writes after a dot. The path is a test path - what `$record:{…}` holds - where a dot, a bracket, a + * brace or a backslash in a key would read as part of the path; a key holding one is not written as a path here. + */ +const PATH_KEY = /^[^.[\]{}\\]+$/; /** The operation an operand is, when it is one: its `{ exp, args }`. */ const operationOf = (arg?: IExpression): IExpressionValue | undefined => @@ -99,7 +102,7 @@ const pathOfOperation = (operation: IExpressionValue, relative: boolean): string if (count !== 2 || from === undefined || typeof key !== 'string') { return undefined; } - return BARE_KEY.test(key) ? `${from}.${key}` : `${from}{${JSON.stringify(key)}}`; + return PATH_KEY.test(key) ? `${from}.${key}` : undefined; } default: return undefined; From 5ea1b30a21890fcbd95af00d6c0c34e53e3ef47f Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 13:31:09 +0200 Subject: [PATCH 173/262] fix(smart-editor): a chip is hovered as the text it replaces, and a hover is of the character under the pointer (qorus#646) David, on a round-7 story: "there are no mouseover / hovers on the functions (size()) or on the chips". A chip replaces its text and has no caret position of its own: a mouse resting on one was mapped to no text, and showed nothing. "Details" (a finger's hover) with a tap on a chip - which puts the caret right after it - asked the server about the character after the chip. The hover of a chip is now what the server says at each part of its text, in one hover, each said once with a rule between them: - the converter names the parts (ISlateConverter.hoverOffsetsOf): DPQL's dpqlHoverOffsets gives the field of `@create.items[*].id`, the `*` of its wildcard and the `id` after it - an index is a number to the server, and a template is one part. Without it, a chip's hover is what the server says at its start; - a mouse resting on a chip, and Details with the caret on a chip (it selected, or right after or before it), ask for those parts; - a chip that draws a hover of its own - a field the record describes, whose props carry a tooltip - shows that, and the server's is not drawn over it as well (chipHasOwnHover). The caret position the browser gives for a point is the boundary nearest it: past the middle of a character it is after it. A hover is of the character under the pointer, so resting on the right half of a `+` asked the server about the space after it, and showed nothing. Where the point is on the character before the caret, that character is the one. Unit: __tests__/smartEditor/chipHover.test.tsx (6 of 8 fail before; the two that pass are the text away from a chip, as before), and dpqlHoverOffsets in accessorChains.test.ts. The stories that show it come with the stand-in servers' hovers in the next commit. --- __tests__/dpqlEditor/accessorChains.test.ts | 15 ++ __tests__/smartEditor/chipHover.test.tsx | 190 ++++++++++++++++++++ src/components/dpqlEditor/dpqlHelpers.ts | 36 ++++ src/components/smartEditor/SmartEditor.tsx | 4 + src/components/smartEditor/types.ts | 7 + src/components/smartEditor/useLspHover.ts | 161 ++++++++++++++++- 6 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 __tests__/smartEditor/chipHover.test.tsx diff --git a/__tests__/dpqlEditor/accessorChains.test.ts b/__tests__/dpqlEditor/accessorChains.test.ts index 17759830..e53e7043 100644 --- a/__tests__/dpqlEditor/accessorChains.test.ts +++ b/__tests__/dpqlEditor/accessorChains.test.ts @@ -3,6 +3,7 @@ // `@create.items[*].id`, and the editor drew the chip `create.items` followed by the plain text `[*].id`. import { dpqlFieldName, + dpqlHoverOffsets, endsDpqlReference, getTagLabel, plainTextToSlate, @@ -69,6 +70,20 @@ describe('a field with accessors is one chip', () => { expect(getTagLabel('@"a\\"b"{*}')).toBe('a"b{*}'); }); + test("names where the server says something of each part of its chip, for the chip's hover", () => { + // the field, the * of the wildcard, the name after it + expect(dpqlHoverOffsets('@create.items[*].id')).toEqual([0, 14, 17]); + // an index is a number to the server: not a part of its own + expect(dpqlHoverOffsets('@items[0].id')).toEqual([0, 10]); + expect(dpqlHoverOffsets('@prices{*}')).toEqual([0, 8]); + expect(dpqlHoverOffsets('@h{"a b"}')).toEqual([0, 3]); + expect(dpqlHoverOffsets('@h{ a, "b c"}')).toEqual([0, 4]); + expect(dpqlHoverOffsets('@"a b"[*]')).toEqual([0, 7]); + // a field alone, and a template, are one part + expect(dpqlHoverOffsets('@name')).toEqual([0]); + expect(dpqlHoverOffsets('$record:{orders[*].id}')).toEqual([0]); + }); + test('typed, an opening bracket does not end the field: an accessor may follow', () => { expect(endsDpqlReference('[')).toBe(false); expect(endsDpqlReference(' ')).toBe(true); diff --git a/__tests__/smartEditor/chipHover.test.tsx b/__tests__/smartEditor/chipHover.test.tsx new file mode 100644 index 00000000..b5899b4e --- /dev/null +++ b/__tests__/smartEditor/chipHover.test.tsx @@ -0,0 +1,190 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A chip is hovered as the text it replaces (qorus#646, David: "there are no mouseover / hovers ... on the chips"). +// +// A chip has no caret position of its own, so a mouse resting on one was mapped to no text and showed nothing, and +// "Details" with a tap on a chip asked for the character after it. The hover of a chip is what the server says at +// each part of its text (`converter.hoverOffsetsOf`), in one hover. +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const dom = vi.hoisted(() => ({ + editor: undefined as HTMLElement | undefined, + chip: undefined as HTMLElement | undefined, + chipNode: undefined as unknown, +})); + +vi.mock('slate-react', () => ({ + ReactEditor: { + toDOMNode: () => dom.editor, + toSlateNode: () => dom.chipNode, + findPath: () => [0, 1], + // off any chip, the pointer is on the text, two characters in + toSlatePoint: () => ({ path: [0, 0], offset: 2 }), + toDOMRange: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, +})); + +import { dpqlSlateConverter } from '../../src/components/dpqlEditor/dpqlHelpers'; +import { useLspHover } from '../../src/components/smartEditor/useLspHover'; +import { ISlateConverter } from '../../src/components/smartEditor/types'; + +const md = (value: string) => ({ kind: 'markdown', value }) as const; +/** What the server says at each character of `size(@create.items[*].id) > 1`. */ +const SAYS: Record> = { + 5: md('**field** `@create.items`'), + 19: md('**operator** `[*]`'), + 22: md('**field** `id`'), + 2: md('**function** `size`'), +}; + +const CHIP = { + type: 'tag', + value: '@create.items[*].id', + label: 'create.items[*].id', + children: [{ text: '' }], +}; +const DOCUMENT = [{ type: 'paragraph', children: [{ text: 'size(' }, CHIP, { text: ') > 1' }] }]; + +let getHover: ReturnType; + +const hookOnAnEditor = ( + options: Parameters[3] = {}, + selection: unknown = null, + converter: ISlateConverter = dpqlSlateConverter +) => { + getHover = vi.fn(async (_line: number, character: number) => SAYS[character] ?? null); + const session = { client: { getHover }, isReady: true, isContextReady: true } as any; + const editorRef = { current: { selection, children: DOCUMENT } } as any; + return renderHook(() => useLspHover(session, editorRef, converter, options)); +}; + +/** The mouse resting at a point of the editor: on the chip (`onChip`) or on its text. */ +const restMouse = async (onChip: boolean) => { + (document as any).elementFromPoint = () => (onChip ? dom.chip!.firstElementChild : dom.editor); + act(() => { + dom.editor!.dispatchEvent( + new PointerEvent('pointermove', { + pointerType: 'mouse', + clientX: 20, + clientY: 10, + bubbles: true, + }) + ); + }); + await act(() => vi.advanceTimersByTimeAsync(400)); +}; + +beforeEach(() => { + vi.useFakeTimers(); + dom.editor = document.createElement('div'); + dom.chip = document.createElement('span'); + dom.chip.setAttribute('data-slate-void', 'true'); + dom.chip.appendChild(document.createElement('span')); + dom.editor.appendChild(dom.chip); + dom.chipNode = CHIP; + document.body.appendChild(dom.editor); + (document as any).caretRangeFromPoint = () => document.createRange(); +}); + +afterEach(() => { + dom.editor?.remove(); + delete (document as any).caretRangeFromPoint; + delete (document as any).elementFromPoint; + vi.useRealTimers(); +}); + +describe('a mouse resting on a chip', () => { + it('is shown what the server says of each part of its text, in one hover', async () => { + const { result } = hookOnAnEditor(); + await restMouse(true); + + expect(getHover.mock.calls).toEqual([ + [0, 5], + [0, 19], + [0, 22], + ]); + expect(result.current.hoverContent).toEqual( + md('**field** `@create.items`\n\n---\n\n**operator** `[*]`\n\n---\n\n**field** `id`') + ); + }); + + it('is shown a part once where the server says the same of two', async () => { + SAYS[22] = SAYS[5]; + try { + const { result } = hookOnAnEditor(); + await restMouse(true); + expect(result.current.hoverContent?.value).toBe( + '**field** `@create.items`\n\n---\n\n**operator** `[*]`' + ); + } finally { + SAYS[22] = md('**field** `id`'); + } + }); + + it('is shown what the server says at its start, where the converter names no parts', async () => { + const { hoverOffsetsOf: _none, ...converter } = dpqlSlateConverter; + const { result } = hookOnAnEditor({}, null, converter); + await restMouse(true); + + expect(getHover.mock.calls).toEqual([[0, 5]]); + expect(result.current.hoverContent).toEqual(SAYS[5]); + }); + + it("is shown the chip's own hover alone, where it has one: nothing is asked of the server", async () => { + const { result } = hookOnAnEditor({ chipHasOwnHover: () => true }); + await restMouse(true); + + expect(getHover).not.toHaveBeenCalled(); + expect(result.current.hoverContent).toBeNull(); + }); + + it('off the chip, on its text, is shown what the server says there, as before', async () => { + const { result } = hookOnAnEditor(); + await restMouse(false); + + expect(getHover.mock.calls).toEqual([[0, 2]]); + expect(result.current.hoverContent).toEqual(SAYS[2]); + }); +}); + +describe('Details with the caret on a chip', () => { + const caretAt = (path: number[], offset: number) => ({ + anchor: { path, offset }, + focus: { path, offset }, + }); + + it('right after it, where a tap on the chip puts the caret: the chip', async () => { + const { result } = hookOnAnEditor({}, caretAt([0, 2], 0)); + await act(async () => { + await result.current.showAtCaret({ left: 1, top: 1 }); + }); + + expect(getHover.mock.calls).toEqual([ + [0, 5], + [0, 19], + [0, 22], + ]); + expect(result.current.hoverContent?.value).toContain('**operator** `[*]`'); + }); + + it('right before it, or with it selected: the chip', async () => { + for (const selection of [caretAt([0, 0], 'size('.length), caretAt([0, 1, 0], 0)]) { + const { result, unmount } = hookOnAnEditor({}, selection); + await act(async () => { + await result.current.showAtCaret({ left: 1, top: 1 }); + }); + expect(result.current.hoverContent?.value).toContain('**field** `id`'); + unmount(); + } + }); + + it('in the text away from a chip: what the server says at the caret, as before', async () => { + const { result } = hookOnAnEditor({}, caretAt([0, 0], 2)); + await act(async () => { + await result.current.showAtCaret({ left: 1, top: 1 }); + }); + + expect(getHover.mock.calls).toEqual([[0, 2]]); + expect(result.current.hoverContent).toEqual(SAYS[2]); + }); +}); diff --git a/src/components/dpqlEditor/dpqlHelpers.ts b/src/components/dpqlEditor/dpqlHelpers.ts index f30b5563..a12dd627 100644 --- a/src/components/dpqlEditor/dpqlHelpers.ts +++ b/src/components/dpqlEditor/dpqlHelpers.ts @@ -442,12 +442,48 @@ export function getTagLabel(value: string): string { export const endsDpqlReference = (char: string): boolean => !!char && !/^[\p{L}\p{M}\p{N}_.:{[]$/u.test(char); +/** + * Where the server says something of a part of a reference's chip (its text, as written): the field at its start, + * and after it the `*` of each wildcard, each name after a dot and each key in braces - not an index, which the + * server calls a number. A template is one part. + */ +export const dpqlHoverOffsets = (value: string): number[] => { + if (!value.startsWith('@')) { + return [0]; + } + let fieldEnd: number; + if (value[1] === '"') { + fieldEnd = endOfQuoted(value, 2, '"'); + } else { + BARE_FIELD.lastIndex = 0; + fieldEnd = BARE_FIELD.test(value) ? BARE_FIELD.lastIndex : -1; + } + const offsets = [0]; + for (let at = fieldEnd; at > 0 && at < value.length;) { + ACCESSOR.lastIndex = at; + if (!ACCESSOR.test(value)) { + break; + } + const accessor = value.slice(at, ACCESSOR.lastIndex); + if (accessor === '[*]' || accessor === '{*}') { + offsets.push(at + 1); + } else if (accessor.startsWith('.')) { + offsets.push(at + 1); + } else if (accessor.startsWith('{')) { + offsets.push(at + accessor.search(/[^{\s]/)); + } + at = ACCESSOR.lastIndex; + } + return offsets; +}; + /** DPQL converter — handed to `SmartEditor` via the `converter` prop. */ export const dpqlSlateConverter: ISlateConverter = { toSlateNodes: plainTextToSlate, fromSlateNodes: slateToPlainText, selectionToOffset: slateSelectionToOffset, endsReference: endsDpqlReference, + hoverOffsetsOf: dpqlHoverOffsets, }; /** diff --git a/src/components/smartEditor/SmartEditor.tsx b/src/components/smartEditor/SmartEditor.tsx index d7a06bc3..128a0747 100644 --- a/src/components/smartEditor/SmartEditor.tsx +++ b/src/components/smartEditor/SmartEditor.tsx @@ -292,6 +292,10 @@ export const SmartEditor = forwardRef, converter, { enabled: enableHover, debounceMs: hoverDelayMs, + // a chip whose props carry a tooltip (a field the record describes) shows that, not the server's hover too + chipHasOwnHover: (tag) => + !!(tagRenderer as ((tag: ISlateElement) => { tooltip?: unknown }) | undefined)?.(tag) + ?.tooltip, }); /* The hover the popover drawn in this render is of: what it dismisses when it closes, and nothing newer. */ const shownHoverId = hover.hoverId; diff --git a/src/components/smartEditor/types.ts b/src/components/smartEditor/types.ts index b0f49ff3..6cd6bd22 100644 --- a/src/components/smartEditor/types.ts +++ b/src/components/smartEditor/types.ts @@ -57,6 +57,13 @@ export interface ISlateConverter { * drawn as its chip only once the editor is left. */ endsReference?: (char: string) => boolean; + /** + * Where in a chip's text (its tag `value`, as written) the server says something of a part of it: the field of + * `@create.items[*].id`, the `*` of its wildcard, the `id` after it. A chip replaces its text, so the hover a + * mouse resting on it - or "Details" with it selected - shows is what the server says at each of these, in one + * card. Without it, the chip's hover is what the server says at its start. + */ + hoverOffsetsOf?: (tagValue: string) => number[]; } /** diff --git a/src/components/smartEditor/useLspHover.ts b/src/components/smartEditor/useLspHover.ts index ff87996a..a0fe40d6 100644 --- a/src/components/smartEditor/useLspHover.ts +++ b/src/components/smartEditor/useLspHover.ts @@ -12,7 +12,7 @@ import { debounce } from 'lodash'; import { RefObject, useCallback, useEffect, useRef, useState } from 'react'; -import { BaseEditor, Point } from 'slate'; +import { BaseEditor, Node, Path, Point } from 'slate'; import { HistoryEditor } from 'slate-history'; import { ReactEditor } from 'slate-react'; import { isHoveringPointer } from '../../helpers/hoveringPointer'; @@ -64,10 +64,36 @@ function mousePositionToSlatePoint( } if (!domRange) return null; + /* The caret position nearest the point is the boundary between two characters: past the middle of a character + it is after it. A hover is of the character under the pointer, so where the point is on the character before + the caret, that is the one: resting on the right half of a `+` asked the server about the space after it, and + showed nothing (qorus#646). */ + let offset = domRange.startOffset; + const container = domRange.startContainer; + if (container.nodeType === 3 && offset > 0) { + try { + const before = document.createRange(); + before.setStart(container, offset - 1); + before.setEnd(container, offset); + const box = before.getBoundingClientRect(); + if ( + box.width > 0 && + clientX >= box.left && + clientX < box.right && + clientY >= box.top && + clientY <= box.bottom + ) { + offset -= 1; + } + } catch { + // no layout to measure: the caret position stands + } + } + try { return ReactEditor.toSlatePoint( editor, - [domRange.startContainer, domRange.startOffset], + [container, offset], { exactMatch: false, suppressThrow: true } as any ); } catch { @@ -75,11 +101,104 @@ function mousePositionToSlatePoint( } } +/** The chip (a tag element) under `clientX`, `clientY` in the editor, with its path, or null. */ +function chipAtPoint( + editor: BaseEditor & ReactEditor & HistoryEditor, + clientX: number, + clientY: number +): { path: Path; tag: ISlateElement } | null { + try { + // not every document can say what is at a point (jsdom) + const element = + typeof document.elementFromPoint === 'function' ? + document.elementFromPoint(clientX, clientY)?.closest('[data-slate-void="true"]') + : null; + if (!element) return null; + const node = ReactEditor.toSlateNode(editor, element) as ISlateElement; + if (node.type !== 'tag') return null; + return { path: ReactEditor.findPath(editor, node as any), tag: node }; + } catch { + // not this editor's, or no longer in it + return null; + } +} + +/** + * The chip the caret is on, with its path, or null: a chip selected, or the chip the caret touches - right after + * it, where a tap on a chip puts the caret, or right before it. A chip has no caret position inside it. + */ +function chipAtCaret( + editor: BaseEditor & ReactEditor & HistoryEditor +): { path: Path; tag: ISlateElement } | null { + const selection = editor.selection; + const focus = selection?.focus; + if (!focus || focus.path.length < 2) return null; + try { + const [line, child] = focus.path; + const paragraph = Node.get(editor, [line]) as ISlateElement; + const at = (index: number) => { + const node = paragraph.children?.[index] as ISlateElement | undefined; + return node?.type === 'tag' ? { path: [line, index], tag: node } : null; + }; + const own = at(child); + if (own) return own; + if (selection && !Point.equals(selection.anchor, selection.focus)) return null; + const text = paragraph.children?.[child] as { text?: string } | undefined; + if (focus.offset === 0 && child > 0 && at(child - 1)) return at(child - 1); + if (focus.offset === (text?.text ?? '').length && at(child + 1)) return at(child + 1); + return null; + } catch { + return null; + } +} + +/** + * The hover of a chip: what the server says at each part of the text it replaces (`converter.hoverOffsetsOf`), in + * one hover - each said once, in order, a rule between them. A chip has no caret position of its own, so a mouse + * resting on it was mapped to none and showed nothing (qorus#646, David: "there are no mouseover / hovers ... on the + * chips"). + */ +async function chipHover( + client: { getHover(line: number, character: number): Promise }, + nodes: ISlateElement[], + path: Path, + tag: ISlateElement, + converter: ISlateConverter +): Promise { + const plainText = converter.fromSlateNodes(nodes); + // where the chip's text starts: at the chip, before any quotes the converter writes around it + const start = converter.selectionToOffset(nodes, [path[0], path[1]], 0); + const offsets = converter.hoverOffsetsOf?.(String(tag.value ?? '')) ?? [0]; + const answers = await Promise.all( + offsets.map((offset) => { + const { line, character } = offsetToLspPosition(plainText, start + offset); + return client.getHover(line, character); + }) + ); + const parts: ILspMarkupContent[] = []; + answers.forEach((answer) => { + if (answer && !parts.some((part) => part.value === answer.value)) { + parts.push(answer); + } + }); + if (!parts.length) return null; + const kind = parts.every((part) => part.kind === 'markdown') ? 'markdown' : 'plaintext'; + return { + kind, + value: parts.map((part) => part.value).join(kind === 'markdown' ? '\n\n---\n\n' : '\n\n'), + }; +} + export interface IUseLspHoverOptions { /** Default `true`. Set to `false` to disable the hovering pointer's listener. */ enabled?: boolean; /** Debounce window before issuing the LSP request. Default 300ms. */ debounceMs?: number; + /** + * Whether a chip draws a hover of its own (its tag props carry a tooltip: a field the record describes). A mouse + * resting on one is shown that, and not the server's as well; "Details" still shows the server's. + */ + chipHasOwnHover?: (tag: ISlateElement) => boolean; } /** Where a hover is anchored on the screen, in viewport coordinates. */ @@ -127,7 +246,10 @@ export function useLspHover( converter: ISlateConverter, options: IUseLspHoverOptions = {} ): IUseLspHoverResult { - const { enabled = true, debounceMs = 300 } = options; + const { enabled = true, debounceMs = 300, chipHasOwnHover } = options; + // read when the mouse rests, so a new function from the host each render does not re-create the listener + const chipHasOwnHoverRef = useRef(chipHasOwnHover); + chipHasOwnHoverRef.current = chipHasOwnHover; const [hoverContent, setHoverContent] = useState( null @@ -190,13 +312,37 @@ export function useLspHover( ) return; + const nodes = editor.children as ISlateElement[]; + // a chip replaces its text: it is hovered as that text is (`chipHover`) + const chip = chipAtPoint(editor, clientX, clientY); + if (chip && chipHasOwnHoverRef.current?.(chip.tag)) { + // the chip's own hover is shown: one hover over a chip, not two + ++requestIdRef.current; + clearHover(); + return; + } + if (chip) { + const reqId = ++requestIdRef.current; + try { + const content = await chipHover(client, nodes, chip.path, chip.tag, converter); + if (reqId !== requestIdRef.current) return; // stale + if (content) { + showHover(content, { left: clientX, top: clientY }); + } else { + clearHover(); + } + } catch { + clearHover(); + } + return; + } + const point = mousePositionToSlatePoint(editor, clientX, clientY); if (!point) { clearHover(); return; } - const nodes = editor.children as ISlateElement[]; const { line, character } = slatePointToLspPosition( { path: point.path as number[], offset: point.offset }, nodes, @@ -299,6 +445,8 @@ export function useLspHover( nodes, converter ); + // the caret on a chip: its hover is the chip's, as a mouse resting on it would show (`chipHover`) + const chip = chipAtCaret(editor); let anchor: { left: number; top: number }; try { const rect = ReactEditor.toDOMRange(editor, editor.selection).getBoundingClientRect(); @@ -309,7 +457,10 @@ export function useLspHover( fetchHover.current.cancel(); const reqId = ++requestIdRef.current; try { - const content = await client.getHover(line, character); + const content = + chip ? + await chipHover(client, nodes, chip.path, chip.tag, converter) + : await client.getHover(line, character); if (reqId !== requestIdRef.current) return undefined; // stale if (!content) { clearHover(); From 92078d8cae5b8f31079115cf1f8e5c43954e6f41 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 13:31:21 +0200 Subject: [PATCH 174/262] test(stories): the stories' stand-in servers answer hovers as the live Qorus /lsp does (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit David asked, on Components/DpqlEditor · With Accessor Chains, "is DPQL meant to be active here? There are no mouseover / hovers on the functions (size()) or on the chips". Neither stand-in server answered a hover: the DpqlEditor stories' declared no hoverProvider and had no handler, and the shared one every other story draws the editor over (dpqlMockLsp, started for every story in .storybook/preview.tsx: ExpressionField, FormEngine, TemplateField) answered an unknown request with {}, which is no hover. So no DPQL story had hovers at all. Both now declare hoverProvider and answer textDocument/hover as the live server does (dpqlMockHover): - a function's and an operator's hover is the server's own markdown, captured from professor's live /lsp (qorus 8a187191e, which has 1d1b79959 and d046d807) with lib646/scripts/capture-dpql-hovers.qr: __fixtures__/dpqlServerHovers.json says how - 12 functions, 15 operators, [*] and {*} among them; - a field (with its type where dpql/setContext described it), a template, the `*` of a wildcard, a name after a dot, a key and a literal are answered in the shape the server answers them, and punctuation has none. __tests__/dpqlEditor/mockHover.test.ts checks the stand-in against the live server's answers at every character of four texts, with and without the record's fields (liveDpqlHovers.json, captured the same way). Stories (each failing without the stand-in's hovers, or without 5ea1b30a): - DpqlEditor "Hover Says What A Function Is": size, its description; - DpqlEditor "Hover Over A Chip Says What Its Path Is": the chip create.items[*].id says the field, the list wildcard and id in one card; - DpqlEditor "Details On A Chip On A Touch Screen": a tap on the chip, then Details, shows the same; - DpqlEditor "A Chip With Its Own Description Shows One Hover": a field the record describes shows its description, and not the server's hover too; - ExpressionField "Text Mode Hover Says What An Operator Is": the shared stand-in, 1 + 2, resting on the right half of the +. The mouse helpers the SmartEditor stories had (middleOf, restMouseOn, mouseOffThePage) are shared in src/stories/mouse.ts. --- __tests__/dpqlEditor/liveDpqlHovers.json | 682 ++++++++++++++++++ __tests__/dpqlEditor/mockHover.test.ts | 27 + .../dpqlEditor/DpqlEditor.stories.tsx | 170 ++++- .../dpqlEditor/__fixtures__/dpqlMockHover.ts | 248 +++++++ .../__fixtures__/dpqlServerHovers.json | 34 + .../expressions/ExpressionField.stories.tsx | 44 ++ .../form/expressions/dpqlMockLsp.ts | 13 + .../smartEditor/SmartEditor.stories.tsx | 62 +- src/stories/mouse.ts | 73 ++ 9 files changed, 1288 insertions(+), 65 deletions(-) create mode 100644 __tests__/dpqlEditor/liveDpqlHovers.json create mode 100644 __tests__/dpqlEditor/mockHover.test.ts create mode 100644 src/components/dpqlEditor/__fixtures__/dpqlMockHover.ts create mode 100644 src/components/dpqlEditor/__fixtures__/dpqlServerHovers.json create mode 100644 src/stories/mouse.ts diff --git a/__tests__/dpqlEditor/liveDpqlHovers.json b/__tests__/dpqlEditor/liveDpqlHovers.json new file mode 100644 index 00000000..fcbe1518 --- /dev/null +++ b/__tests__/dpqlEditor/liveDpqlHovers.json @@ -0,0 +1,682 @@ +{ + "source": "textDocument/hover at every character of each text, from professor's live Qorus /lsp (qorus 8a187191e) on 2026-10-18, captured with lib646/scripts/capture-dpql-hovers.qr; `fields` is what dpql/setContext was given (none: no context).", + "captures": [ + { + "text": "size(@create.items[*].id) > size(@prices{*}) && @items[0].id != @headers{\"content-type\"} && $local:input == \"x\"", + "fields": { + "create": { + "type": "hash", + "display_name": "Create", + "short_desc": "The order the test case creates" + }, + "create.items": { + "type": "list", + "display_name": "Items", + "short_desc": "The order's line items" + }, + "prices": { + "type": "hash", + "display_name": "Prices", + "short_desc": "The price of each product" + }, + "items": { + "type": "list", + "display_name": "Items", + "short_desc": "The order's items" + }, + "headers": { + "type": "hash", + "display_name": "Headers", + "short_desc": "The response headers" + }, + "name": { + "type": "string", + "display_name": "Name" + } + }, + "hovers": [ + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + "**field** `@create.items`\n\nType: `list`", + null, + "**operator** `[*]`\n\n**list wildcard ([*])**\n\nApplies the accessors after [*] to each item of a list, returning a new list\n\nReturns: `list`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + "**field** `@prices`\n\nType: `hash`", + null, + "**operator** `{*}`\n\n**hash wildcard ({*})**\n\nApplies the accessors after {*} to each value of a hash, returning a new list\n\nReturns: `list`", + null, + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + "**field** `@items`\n\nType: `list`", + null, + "**numeric literal** `0`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + "**field** `@headers`\n\nType: `hash`", + null, + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`" + ] + }, + { + "text": "size(@create.items[*].id) > size(@prices{*}) && @items[0].id != @headers{\"content-type\"} && $local:input == \"x\"", + "fields": null, + "hovers": [ + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + "**field** `@create.items`", + null, + "**operator** `[*]`\n\n**list wildcard ([*])**\n\nApplies the accessors after [*] to each item of a list, returning a new list\n\nReturns: `list`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + "**field** `@prices`", + null, + "**operator** `{*}`\n\n**hash wildcard ({*})**\n\nApplies the accessors after {*} to each value of a hash, returning a new list\n\nReturns: `list`", + null, + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + "**field** `@items`", + null, + "**numeric literal** `0`", + null, + null, + "**field** `id`", + "**field** `id`", + null, + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + "**field** `@headers`", + null, + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + "**field** `\"content-type\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + "**template** `$local:input`\n\nContext: `local`\nValue: `input`", + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`" + ] + }, + { + "text": "@name == \"Alice\" && contains(\"$local:input\", \"es\") && @age > $config:min_age", + "fields": null, + "hovers": [ + "**field** `@name`", + "**field** `@name`", + "**field** `@name`", + "**field** `@name`", + "**field** `@name`", + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + "**string literal** `\"Alice\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + "**string literal** `\"$local:input\"`", + null, + null, + "**string literal** `\"es\"`", + "**string literal** `\"es\"`", + "**string literal** `\"es\"`", + "**string literal** `\"es\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@age`", + "**field** `@age`", + "**field** `@age`", + "**field** `@age`", + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`", + "**template** `$config:min_age`\n\nContext: `config`\nValue: `min_age`" + ] + }, + { + "text": "contains(@a, \"x\") || startsWith(@a, \"y\") && endsWith(@a, \"z\") && concat(@a, \"b\") == \"c\" && substr(@a, 1, 2) != \"\" && join(@l, \",\") == \"\" && regex(@a, \"x\") && boolean(@a) && length(@a) >= 1 && @n + 1 < 2 && @n - 1 <= 2 && !@b && @n * 2 / 3 > 1 && size(@l) > 0 && format_date(@d, \"YYYY\") == \"\" && format_number(@n, \".,2\") == \"\"", + "fields": null, + "hovers": [ + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + null, + null, + "**operator** `||`\n\n**Logical Or**\n\nTrue when at least one part is true; it stops at the first part that is true.\n\n`||(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is true, the whole is true.", + "**operator** `||`\n\n**Logical Or**\n\nTrue when at least one part is true; it stops at the first part that is true.\n\n`||(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is true, the whole is true.", + null, + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"y\"`", + "**string literal** `\"y\"`", + "**string literal** `\"y\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"z\"`", + "**string literal** `\"z\"`", + "**string literal** `\"z\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"b\"`", + "**string literal** `\"b\"`", + "**string literal** `\"b\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"c\"`", + "**string literal** `\"c\"`", + "**string literal** `\"c\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**numeric literal** `1`", + null, + null, + "**numeric literal** `2`", + null, + null, + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + null, + "**field** `@l`", + "**field** `@l`", + null, + null, + "**string literal** `\",\"`", + "**string literal** `\",\"`", + "**string literal** `\",\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + "**string literal** `\"x\"`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + "**function** `boolean`", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@a`", + "**field** `@a`", + null, + null, + "**operator** `>=`\n\n**greater than or equals (>=)**\n\nReturns True if the first argument is greater than or equal to the second argument\n\n`@Value >= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `>=`\n\n**greater than or equals (>=)**\n\nReturns True if the first argument is greater than or equal to the second argument\n\n`@Value >= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `1`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@n`", + "**field** `@n`", + null, + "**operator** `+`\n\n**addition (+)**\n\nAdds values and returns the result\n\n`+(auto Value To Add, ...) → auto`\n\n- `Value To Add`: A value to add", + null, + "**numeric literal** `1`", + null, + "**operator** `<`\n\n**less than (<)**\n\nReturns True if the first argument is strictly less than the second argument\n\n`@Value < auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `2`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@n`", + "**field** `@n`", + null, + "**operator** `-`\n\n**subtraction (-)**\n\nSubtracts the second value from the first\n\n`-(auto Value, auto Subtrahend) → auto`\n\n- `Value`: The value to subtract from\n- `Subtrahend`: The value to subtract", + null, + "**numeric literal** `1`", + null, + "**operator** `<=`\n\n**less than or equals (<=)**\n\nReturns True if the first argument is less than or equal to the second argument\n\n`@Value <= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `<=`\n\n**less than or equals (<=)**\n\nReturns True if the first argument is less than or equal to the second argument\n\n`@Value <= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `2`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**operator** `!`\n\n**Logical Not**\n\nTrue when its condition is false, and false when it is true.\n\n`!(any Value To Invert) → bool`\n\n- `Value To Invert`: The condition to turn around.", + "**field** `@b`", + "**field** `@b`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**field** `@n`", + "**field** `@n`", + null, + "**operator** `*`\n\n**multiplication (*)**\n\nMultiplies numbers and returns the result\n\n`*(number Value To Multiply, ...) → auto`\n\n- `Value To Multiply`: A value to multiply", + null, + "**numeric literal** `2`", + null, + "**operator** `/`\n\n**division (/)**\n\nDivides the first number by the second\n\n`/(number Dividend, number Divisor) → auto`\n\n- `Dividend`: The value to divide\n- `Divisor`: The divisor", + null, + "**numeric literal** `3`", + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `1`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + null, + "**field** `@l`", + "**field** `@l`", + null, + null, + "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**numeric literal** `0`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + null, + "**field** `@d`", + "**field** `@d`", + null, + null, + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + "**string literal** `\"YYYY\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`", + null, + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + null, + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + null, + "**field** `@n`", + "**field** `@n`", + null, + null, + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + "**string literal** `\".,2\"`", + null, + null, + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + null, + "**string literal** `\"\"`", + "**string literal** `\"\"`" + ] + } + ] +} \ No newline at end of file diff --git a/__tests__/dpqlEditor/mockHover.test.ts b/__tests__/dpqlEditor/mockHover.test.ts new file mode 100644 index 00000000..f7845672 --- /dev/null +++ b/__tests__/dpqlEditor/mockHover.test.ts @@ -0,0 +1,27 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// The stories' stand-in servers answer a hover as the live Qorus `/lsp` does (qorus#646, David: "there are no +// mouseover / hovers on the functions or on the chips"). Checked against the live server's answers at every +// character of four DPQL texts, captured from professor's `/lsp` (liveDpqlHovers.json says how). +import { mockHoverDpql } from '../../src/components/dpqlEditor/__fixtures__/dpqlMockHover'; +import LIVE from './liveDpqlHovers.json'; + +describe("the stand-in's hover", () => { + for (const { text, fields, hovers } of LIVE.captures) { + test(`is the live server's at every character of ${JSON.stringify(text.slice(0, 40))}… (${ + fields ? 'with' : 'without' + } the record's fields)`, () => { + const answered = hovers.map( + (_live, character) => + mockHoverDpql(text, { line: 0, character }, fields ?? undefined)?.contents.value ?? null + ); + // character by character, so a difference names where it is + expect(answered.map((value, character) => [character, text[character], value])).toEqual( + hovers.map((value, character) => [character, text[character], value]) + ); + }); + } + + test('has none on a line the text does not have', () => { + expect(mockHoverDpql('@a', { line: 3, character: 0 })).toBeNull(); + }); +}); diff --git a/src/components/dpqlEditor/DpqlEditor.stories.tsx b/src/components/dpqlEditor/DpqlEditor.stories.tsx index d1816aa3..07ce90b5 100644 --- a/src/components/dpqlEditor/DpqlEditor.stories.tsx +++ b/src/components/dpqlEditor/DpqlEditor.stories.tsx @@ -10,11 +10,27 @@ import { MOCK_LSP_URL, } from '../smartEditor/__fixtures__/mockLspServer'; import { mockTokenizeDpql } from '../form/expressions/dpqlMockLanguage'; +import { mockHoverDpql } from './__fixtures__/dpqlMockHover'; import { DpqlEditor } from './DpqlEditor'; import { TDpqlFsmContext } from './types'; +import { withCoarsePointer } from '../../stories/coarsePointer'; +import { middleOf, middleOfElement, mouseOffThePage, restMouseOn } from '../../stories/mouse'; +import { withTouchScreen } from '../../stories/touchScreen'; +import { PHONE_VIEWPORT } from '../../stories/tracking'; let lsp: IMockLspServer; +/** The record the stories' server describes (`dpql/setContext`): what a field's hover says of it. */ +const STORY_RECORD_FIELDS = { + name: { + display_name: 'Name', + short_desc: "The customer's full name, as they gave it", + type: { name: 'string' }, + }, + status: { display_name: 'Status', type: { name: 'string' } }, + age: { display_name: 'Age', type: { name: 'int' } }, +}; + interface IDpqlEditorStoryArgs { value?: string; onChange?: (v: string) => void; @@ -66,8 +82,16 @@ const meta = { signatureHelpProvider: { triggerCharacters: ['(', ',', ' ', '-', '='], }, + // the real server answers hovers: functions, operators, fields, accessors, templates (qorus#646) + hoverProvider: true, }, handlers: { + 'textDocument/hover': (msg, server) => + mockHoverDpql( + server.textOf(msg.params?.textDocument?.uri), + msg.params?.position ?? { line: 0, character: 0 }, + STORY_RECORD_FIELDS + ), 'textDocument/semanticTokens/full': (msg, server) => ({ data: mockTokenizeDpql(server.textOf(msg.params?.textDocument?.uri)), }), @@ -231,11 +255,7 @@ const meta = { 'dpql/setContext': (msg) => ({ provider: msg.params?.provider ?? null, recordType: msg.params?.recordType ?? 'record', - fields: { - name: { display_name: 'Name', type: { name: 'string' } }, - status: { display_name: 'Status', type: { name: 'string' } }, - age: { display_name: 'Age', type: { name: 'int' } }, - }, + fields: STORY_RECORD_FIELDS, }), 'dpql/validate': () => ({ diagnostics: [] }), @@ -567,6 +587,146 @@ export const WithAccessorChains: Story = { }, }; +/** The hovers the editor shows, as their text. */ +const shownHovers = () => + Array.from(document.querySelectorAll('.reqore-popover-content')) + .map((popover) => (popover.textContent ?? '').replace(/\s+/g, ' ').trim()) + .filter(Boolean); + +/** The editor, once its server is connected and the record described: a hover asked for from now on is answered. */ +const readyEditor = async (canvasElement: HTMLElement) => { + await waitFor( + () => + expect(canvasElement.textContent).not.toMatch(/Connecting to language server|Loading schema/), + { timeout: 10000 } + ); + return within(canvasElement).getByRole('textbox'); +}; + +/** The chip the editor draws for `label`. */ +const chipFor = (editable: HTMLElement, label: string) => + waitFor(() => { + const chip = Array.from(editable.querySelectorAll('.reqore-tag')).find( + (each) => (each.textContent ?? '').replace(/\uFEFF/g, '').trim() === label + ); + expect(chip, `the chip ${label}`).toBeTruthy(); + return chip!; + }); + +export const HoverSaysWhatAFunctionIs: Story = { + args: { ...WithAccessorChains.args, onChange: fn() }, + parameters: { + docs: { + description: { + story: + 'Renders the DpqlEditor with size(@create.items[*].id) > size(@prices{*}) && … and rests the mouse on size: the hover says what the server says of it - "function size", "Get Size", what it returns and its argument - as the live Qorus /lsp answers (qorus#646, David: "there are no mouseover / hovers on the functions").', + }, + }, + }, + play: async ({ canvasElement }) => { + const editable = await readyEditor(canvasElement); + try { + await restMouseOn(editable, middleOf(editable, 'size')); + await waitFor(() => expect(shownHovers()).toEqual([expect.stringContaining('Get Size')]), { + timeout: 10000, + }); + await expect(shownHovers()[0]).toContain('Returns the size of the operand'); + } finally { + await mouseOffThePage(); + } + }, +}; + +export const HoverOverAChipSaysWhatItsPathIs: Story = { + args: { ...WithAccessorChains.args, onChange: fn() }, + parameters: { + docs: { + description: { + story: + 'Renders the DpqlEditor with the field path @create.items[*].id drawn as one chip, and rests the mouse on the chip: the hover says what the server says of each part of it - the field @create.items, the list wildcard [*], and the field id after it - as its text would, in one card (qorus#646, David: "there are no mouseover / hovers on the chips").', + }, + }, + }, + play: async ({ canvasElement }) => { + const editable = await readyEditor(canvasElement); + const chip = await chipFor(editable, 'create.items[*].id'); + try { + await restMouseOn(chip, middleOfElement(chip)); + await waitFor( + () => expect(shownHovers()).toEqual([expect.stringContaining('list wildcard ([*])')]), + { timeout: 10000 } + ); + const [hover] = shownHovers(); + await expect(hover).toContain('@create.items'); + await expect(hover).toMatch(/field\s*id/); + } finally { + await mouseOffThePage(); + } + }, +}; + +export const DetailsOnAChipOnATouchScreen: Story = { + tags: ['touch'], + args: { ...WithAccessorChains.args, onChange: fn() }, + decorators: [withCoarsePointer], + parameters: { + qlip: { viewport: PHONE_VIEWPORT }, + docs: { + description: { + story: + 'Renders the same text on a phone (390 px, touch) and taps the chip create.items[*].id, then Details: a finger cannot hover, and Details shows what the server says of the chip - the field @create.items, the list wildcard [*] and the field id - in a card under the button.', + }, + }, + }, + play: async ({ canvasElement }) => { + await withTouchScreen(async (touch) => { + const editable = await readyEditor(canvasElement); + const chip = await chipFor(editable, 'create.items[*].id'); + await touch.tap(chip); + const details = await waitFor( + () => { + const button = canvasElement.querySelector('.smart-editor-details'); + expect(button, 'the Details button').toBeTruthy(); + return button!; + }, + { timeout: 10000 } + ); + await touch.tap(details); + await waitFor( + () => expect(shownHovers()).toEqual([expect.stringContaining('list wildcard ([*])')]), + { timeout: 10000 } + ); + await expect(shownHovers()[0]).toContain('@create.items'); + }); + }, +}; + +export const AChipWithItsOwnDescriptionShowsOneHover: Story = { + args: { value: '@name == "Alice"', provider: 'orders-db', recordType: 'record', onChange: fn() }, + parameters: { + docs: { + description: { + story: + 'Renders @name == "Alice" with the record described, and rests the mouse on the chip Name: the chip shows its own description - "The customer\'s full name, as they gave it" - and the server\'s hover is not drawn over it as well. One hover over a chip, not two.', + }, + }, + }, + play: async ({ canvasElement }) => { + const editable = await readyEditor(canvasElement); + const chip = await chipFor(editable, 'Name'); + try { + await restMouseOn(chip, middleOfElement(chip)); + await waitFor( + () => expect(shownHovers()).toEqual([expect.stringContaining("The customer's full name")]), + { timeout: 10000 } + ); + await expect(shownHovers().join(' ')).not.toContain('Type: string'); + } finally { + await mouseOffThePage(); + } + }, +}; + export const AccessorChainTypedOutIsAChip: Story = { args: { value: '', onChange: fn() }, parameters: { diff --git a/src/components/dpqlEditor/__fixtures__/dpqlMockHover.ts b/src/components/dpqlEditor/__fixtures__/dpqlMockHover.ts new file mode 100644 index 00000000..14247041 --- /dev/null +++ b/src/components/dpqlEditor/__fixtures__/dpqlMockHover.ts @@ -0,0 +1,248 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// What the Qorus `/lsp` handler answers to `textDocument/hover` on a DPQL document, for the stories' stand-in +// servers (qorus#646, David: "there are no mouseover / hovers on the functions or on the chips"). +// +// A function's and an operator's hover is the server's own markdown, captured from professor's live `/lsp` +// (`dpqlServerHovers.json` says how). A field, a template, an accessor and a literal are answered in the shape the +// server answers them, as captured there: `**field** \`@create.items\`` and its type when the record says it, +// `**template** \`$local:input\``, `**operator** \`[*]\`` on the `*` of a wildcard, `**field** \`id\`` on a name +// after a dot, `**numeric literal** \`0\``, `**string literal** \`"x"\``. Brackets, braces, dots, parentheses and +// commas have none. +import SERVER_HOVERS from './dpqlServerHovers.json'; + +/** The record's fields, as `dpql/setContext` gives them: each field's type, by name. */ +export type TDpqlHoverFields = Record< + string, + { type?: string | string[] | { name?: string } } | undefined +>; + +export interface IDpqlMockHover { + contents: { kind: 'markdown'; value: string }; +} + +const FUNCTIONS: Record = SERVER_HOVERS.functions; +const OPERATORS: Record = SERVER_HOVERS.operators; +/** The operators, longest first, so `>=` is read before `>`. */ +const OPERATOR_SYMBOLS = [ + '==', + '!=', + '>=', + '<=', + '&&', + '||', + '=~', + '!~', + '>', + '<', + '+', + '-', + '*', + '/', + '!', +]; + +const NAME_START = /[\p{L}_]/u; +const NAME_PART = /[\p{L}\p{M}\p{N}_]/u; + +const markdown = (value: string): IDpqlMockHover => ({ contents: { kind: 'markdown', value } }); + +/** A field's type as the server writes it: a list or a hash of anything, else the type. */ +const typeOf = (fields: TDpqlHoverFields | undefined, name: string): string | undefined => { + const declared = fields?.[name]?.type; + const type = + typeof declared === 'string' ? declared + : Array.isArray(declared) ? declared.join('|') + : declared?.name; + if (!type) { + return undefined; + } + return type === 'list' || type === 'hash' ? `${type}` : type; +}; + +/** One thing in the text that has a hover: where it is, and what is said of it. */ +interface IHoverSpan { + start: number; + end: number; + value?: string; +} + +/** Where a string literal that opens at `at` ends (after its closing quote), or the end of the line. */ +const endOfString = (line: string, at: number): number => { + const quote = line[at]; + for (let i = at + 1; i < line.length; i += 1) { + if (line[i] === '\\') { + i += 1; + } else if (line[i] === quote) { + return i + 1; + } + } + return line.length; +}; + +/** Where a name that starts at `at` ends. */ +const endOfName = (line: string, at: number, dashes = false): number => { + let i = at; + while (i < line.length) { + if (NAME_PART.test(line[i])) { + i += 1; + } else if (dashes && line[i] === '-' && /[a-zA-Z]/.test(line[i + 1] ?? '')) { + i += 2; + } else { + break; + } + } + return i; +}; + +/** The accessors after a field that ends at `at`, as spans: the `*` of a wildcard, a name, a key, an index. */ +const accessorSpans = (line: string, at: number, spans: IHoverSpan[]): number => { + let i = at; + for (;;) { + if (line[i] === '.' && NAME_START.test(line[i + 1] ?? '')) { + const end = endOfName(line, i + 1, true); + spans.push({ start: i + 1, end, value: `**field** \`${line.slice(i + 1, end)}\`` }); + i = end; + } else if (line[i] === '[' && line[i + 1] === '*' && line[i + 2] === ']') { + spans.push({ start: i + 1, end: i + 2, value: OPERATORS['[*]'] }); + i += 3; + } else if (line[i] === '{' && line[i + 1] === '*' && line[i + 2] === '}') { + spans.push({ start: i + 1, end: i + 2, value: OPERATORS['{*}'] }); + i += 3; + } else if (line[i] === '[' && /^-?\d+\]/.test(line.slice(i + 1))) { + const close = line.indexOf(']', i); + spans.push({ + start: i + 1, + end: close, + value: `**numeric literal** \`${line.slice(i + 1, close)}\``, + }); + i = close + 1; + } else if (line[i] === '{') { + // keys in braces: `{"content-type"}`, `{b}`, `{a, "b c"}` + let j = i + 1; + for (;;) { + while (line[j] === ' ' || line[j] === ',') { + j += 1; + } + if (line[j] === '"' || line[j] === "'") { + const end = endOfString(line, j); + spans.push({ start: j, end, value: `**field** \`${line.slice(j, end)}\`` }); + j = end; + } else if (NAME_START.test(line[j] ?? '')) { + const end = endOfName(line, j); + spans.push({ start: j, end, value: `**field** \`${line.slice(j, end)}\`` }); + j = end; + } else { + break; + } + } + if (line[j] !== '}') { + return i; + } + i = j + 1; + } else { + return i; + } + } +}; + +/** Every span in `line` that the server answers a hover for. */ +const hoverSpans = (line: string, fields?: TDpqlHoverFields): IHoverSpan[] => { + const spans: IHoverSpan[] = []; + let i = 0; + while (i < line.length) { + const char = line[i]; + if (char === '"' || char === "'") { + const end = endOfString(line, i); + spans.push({ start: i, end, value: `**string literal** \`${line.slice(i, end)}\`` }); + i = end; + } else if (char === '@') { + let end: number; + if (line[i + 1] === '"') { + end = endOfString(line, i + 1); + } else { + end = i + 1; + while ( + end < line.length && + (NAME_PART.test(line[end]) || (line[end] === '.' && NAME_START.test(line[end + 1] ?? ''))) + ) { + end += 1; + } + } + const reference = line.slice(i, end); + const type = typeOf(fields, reference.slice(1)); + spans.push({ + start: i, + end, + value: `**field** \`${reference}\`${type ? `\n\nType: \`${type}\`` : ''}`, + }); + i = accessorSpans(line, end, spans); + } else if (char === '$' && /^\$\w+:/.test(line.slice(i))) { + const colon = line.indexOf(':', i); + let end = colon + 1; + if (line[end] === '{') { + let depth = 0; + for (; end < line.length; end += 1) { + if (line[end] === '\\') { + end += 1; + } else if (line[end] === '{') { + depth += 1; + } else if (line[end] === '}' && --depth === 0) { + end += 1; + break; + } + } + } else { + while (end < line.length && /[\w.]/.test(line[end])) { + end += 1; + } + } + const raw = line.slice(colon + 1, end); + const value = raw.startsWith('{') && raw.endsWith('}') ? raw.slice(1, -1) : raw; + spans.push({ + start: i, + end, + value: `**template** \`${line.slice(i, end)}\`\n\nContext: \`${line.slice(i + 1, colon)}\`\nValue: \`${value}\``, + }); + i = end; + } else if (/\d/.test(char)) { + let end = i; + while (end < line.length && /[\d.]/.test(line[end])) { + end += 1; + } + spans.push({ start: i, end, value: `**numeric literal** \`${line.slice(i, end)}\`` }); + i = end; + } else if (NAME_START.test(char)) { + const end = endOfName(line, i); + const name = line.slice(i, end); + // a function is a name its call follows + const isCall = /^\s*\(/.test(line.slice(end)); + spans.push({ start: i, end, value: isCall ? FUNCTIONS[name] : undefined }); + i = end; + } else { + const operator = OPERATOR_SYMBOLS.find((symbol) => line.startsWith(symbol, i)); + if (operator) { + spans.push({ start: i, end: i + operator.length, value: OPERATORS[operator] }); + i += operator.length; + } else { + i += 1; + } + } + } + return spans; +}; + +/** + * The hover the server answers at `position` in `text` - in the shape the live `/lsp` answers it - or null where + * it answers none. `fields` is what `dpql/setContext` gave the document: a field it declares has its type. + */ +export const mockHoverDpql = ( + text: string, + position: { line: number; character: number }, + fields?: TDpqlHoverFields +): IDpqlMockHover | null => { + const line = text.split('\n')[position.line] ?? ''; + const span = hoverSpans(line, fields).find( + ({ start, end }) => position.character >= start && position.character < end + ); + return span?.value ? markdown(span.value) : null; +}; diff --git a/src/components/dpqlEditor/__fixtures__/dpqlServerHovers.json b/src/components/dpqlEditor/__fixtures__/dpqlServerHovers.json new file mode 100644 index 00000000..933e7f54 --- /dev/null +++ b/src/components/dpqlEditor/__fixtures__/dpqlServerHovers.json @@ -0,0 +1,34 @@ +{ + "source": "Captured from the textDocument/hover responses of professor's live Qorus /lsp (wss://192.168.16.12/lsp, qorus 8a187191e, which contains 1d1b79959 and d046d807) on 2026-10-18, with lib646/scripts/capture-dpql-hovers.qr: every character of three DPQL texts, with and without dpql/setContext. Each entry is the server's markdown for that function or operator, as it sent it.", + "functions": { + "boolean": "**function** `boolean`", + "concat": "**function** `concat`\n\n**concat**\n\nConcatenates multiple values as strings\n\n`concat(auto Value, ...) → string`\n\n- `Value`: A value to concatenate", + "contains": "**function** `contains`\n\n**String Contains**\n\nReturns True if the given string contains the given characters\n\n`contains(softstring String Value, softstring Substring, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Substring`: The characters to check if they are anywhere in the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "endsWith": "**function** `endsWith`\n\n**String Ends With**\n\nReturns True if the given string ends with the given characters when converted to a string\n\n`endsWith(softstring String Value, softstring End String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `End String`: The characters to check at the end of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "format_date": "**function** `format_date`\n\n**Format Date**\n\nThe 'format date' function formats a date as a string\n\n`format_date(date Date To Format, softstring Format String) → string`\n\n- `Date To Format`: The date/time value to format\n- `Format String`: The format string", + "format_number": "**function** `format_number`\n\n**Format Number**\n\nThe 'format number' function formats a number as a string\n\n`format_number(number Number To Format, *string Thousands Separator, softstring Decimal Separator, int Precision) → string`\n\n- `Number To Format`: The number value to format\n- `Thousands Separator`: The thousands separator (default: ',')\n- `Decimal Separator`: The decimal separator (default: '.')\n- `Precision`: A number giving the number of digits after the decimal point (default: 2)", + "join": "**function** `join`\n\n**Join List Elements**\n\nReturns a string made from joining all list elements with a separator string\n\n`join(*softlist List To Join, softstring Separator String) → string`\n\n- `List To Join`: The list to join\n- `Separator String`: The separator string to use to separate elements in the output string", + "length": "**function** `length`\n\n**Get Length**\n\nReturns the length of the operand\n\n`length(auto Value) → int`\n\n- `Value`: The value to report the size for", + "regex": "**function** `regex`\n\n**Regular Expression Match**\n\nReturns True if the first argument matches the second as a regular expression pattern\n\n`regex(softstring String Value, softstring Regular Expression, *list Regex Options) → bool`\n\n- `String Value`: The string value to be matched to the next argument\n- `Regular Expression`: The regular expression to use to match with the first argument\n- `Regex Options`: The regular expression options to use when performing the match", + "size": "**function** `size`\n\n**Get Size**\n\nReturns the size of the operand\n\n`size(auto Value) → int`\n\n- `Value`: The value to report the size for", + "startsWith": "**function** `startsWith`\n\n**String Starts With**\n\nReturns `True` if the given value starts with the given characters when converted to a string\n\n`startsWith(softstring String Value, softstring Start String, bool Ignore Case?) → bool`\n\n- `String Value`: The string value to check\n- `Start String`: The characters to check at the beginning of the first argument\n- `Ignore Case?`: If True then case will be ignored when checking the string (default: False)", + "substr": "**function** `substr`\n\n**Get Substring**\n\nReturns a substring from a string\n\n`substr(softstring String Value, int Start Character, *int Length) → string`\n\n- `String Value`: The string value where the substring will be taken from\n- `Start Character`: The starting character position where the first character is 0\n- `Length`: The maximum number of characters to return" + }, + "operators": { + "!": "**operator** `!`\n\n**Logical Not**\n\nTrue when its condition is false, and false when it is true.\n\n`!(any Value To Invert) → bool`\n\n- `Value To Invert`: The condition to turn around.", + "!=": "**operator** `!=`\n\n**not equals (!=)**\n\nReturns True if the arguments are not equal\n\n`@Value != auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "&&": "**operator** `&&`\n\n**Logical And**\n\nTrue when every part is true; it stops at the first part that is false.\n\n`&&(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is false, the whole is false.", + "*": "**operator** `*`\n\n**multiplication (*)**\n\nMultiplies numbers and returns the result\n\n`*(number Value To Multiply, ...) → auto`\n\n- `Value To Multiply`: A value to multiply", + "+": "**operator** `+`\n\n**addition (+)**\n\nAdds values and returns the result\n\n`+(auto Value To Add, ...) → auto`\n\n- `Value To Add`: A value to add", + "-": "**operator** `-`\n\n**subtraction (-)**\n\nSubtracts the second value from the first\n\n`-(auto Value, auto Subtrahend) → auto`\n\n- `Value`: The value to subtract from\n- `Subtrahend`: The value to subtract", + "/": "**operator** `/`\n\n**division (/)**\n\nDivides the first number by the second\n\n`/(number Dividend, number Divisor) → auto`\n\n- `Dividend`: The value to divide\n- `Divisor`: The divisor", + "<": "**operator** `<`\n\n**less than (<)**\n\nReturns True if the first argument is strictly less than the second argument\n\n`@Value < auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "<=": "**operator** `<=`\n\n**less than or equals (<=)**\n\nReturns True if the first argument is less than or equal to the second argument\n\n`@Value <= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "==": "**operator** `==`\n\n**equals (==)**\n\nReturns True if the two arguments are logically equal; types are converted if necessary\n\n`@Value == auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + ">": "**operator** `>`\n\n**greater than (>)**\n\nReturns True if the first argument is strictly greater than the second argument\n\n`@Value > auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + ">=": "**operator** `>=`\n\n**greater than or equals (>=)**\n\nReturns True if the first argument is greater than or equal to the second argument\n\n`@Value >= auto Value → bool`\n\n- `Value`: A value to compare\n- `Value`: A value to compare", + "[*]": "**operator** `[*]`\n\n**list wildcard ([*])**\n\nApplies the accessors after [*] to each item of a list, returning a new list\n\nReturns: `list`", + "{*}": "**operator** `{*}`\n\n**hash wildcard ({*})**\n\nApplies the accessors after {*} to each value of a hash, returning a new list\n\nReturns: `list`", + "||": "**operator** `||`\n\n**Logical Or**\n\nTrue when at least one part is true; it stops at the first part that is true.\n\n`||(any Value, ...) → bool`\n\n- `Value`: A condition; if any part is true, the whole is true." + } +} \ No newline at end of file diff --git a/src/components/form/expressions/ExpressionField.stories.tsx b/src/components/form/expressions/ExpressionField.stories.tsx index 11c0f36b..65b59081 100644 --- a/src/components/form/expressions/ExpressionField.stories.tsx +++ b/src/components/form/expressions/ExpressionField.stories.tsx @@ -12,6 +12,7 @@ import { dpqlMockParseCalls } from './dpqlMockLsp'; import { withCoarsePointer } from '../../../stories/coarsePointer'; import { _testsWithViewport } from '../../../stories/Tests/utils'; import { PHONE_VIEWPORT } from '../../../stories/tracking'; +import { middleOf, mouseOffThePage, restMouseOn } from '../../../stories/mouse'; import { ExpressionField } from './ExpressionField'; import { mockExpressions } from './mockExpressions'; import { IExpression } from './types'; @@ -894,6 +895,49 @@ export const TextMode: Story = { }, }; +export const TextModeHoverSaysWhatAnOperatorIs: Story = { + args: { + value: { + is_expression: true, + value: { + exp: '+', + args: [ + { type: 'int', value: 1 }, + { type: 'int', value: 2 }, + ], + }, + }, + defaultMode: 'text', + }, + parameters: { + docs: { + description: { + story: + 'Renders ExpressionField in Text (DPQL) mode with 1 + 2, over the stories\' stand-in server, and rests the mouse on +: the hover says what the server says of it - "addition (+)", what it does - as the live Qorus /lsp answers. Every story that draws the DPQL editor over the shared stand-in gets the server\'s hovers (qorus#646).', + }, + }, + }, + async play({ canvasElement }) { + await waitForLspIdle(canvasElement); + const editable = canvasElement.querySelector('[contenteditable="true"]')!; + await waitFor(() => expect(editable.textContent).toContain('1 + 2')); + try { + await restMouseOn(editable, middleOf(editable, '+')); + await waitFor( + () => + expect( + Array.from(document.querySelectorAll('.reqore-popover-content')).map((popover) => + (popover.textContent ?? '').replace(/\s+/g, ' ') + ) + ).toEqual([expect.stringContaining('addition (+)')]), + { timeout: 10000 } + ); + } finally { + await mouseOffThePage(); + } + }, +}; + export const TextModeReferenceTypedOutIsAChip: Story = { args: { value: { is_expression: true, value: { args: [] } }, diff --git a/src/components/form/expressions/dpqlMockLsp.ts b/src/components/form/expressions/dpqlMockLsp.ts index 68871221..1539c1bf 100644 --- a/src/components/form/expressions/dpqlMockLsp.ts +++ b/src/components/form/expressions/dpqlMockLsp.ts @@ -7,6 +7,7 @@ // components make on their own. A story that needs different handlers starts // its own server on the URL and replaces this one (see `createMockLspServer`). // The answers come from `dpqlMockLanguage`, which follows the real server. +import { mockHoverDpql } from '../../dpqlEditor/__fixtures__/dpqlMockHover'; import { createMockLspServer, DEFAULT_LSP_CAPABILITIES, @@ -159,6 +160,16 @@ const DPQL_MOCK_HANDLERS: Record = { 'dpql/validate': () => ({ diagnostics: [] }), + // as the live `/lsp` answers it (dpqlMockHover): a field the record declares has its type + 'textDocument/hover': (msg, server) => { + const uri = String(msg.params?.textDocument?.uri); + return mockHoverDpql( + server.textOf(uri), + (msg.params?.position as { line: number; character: number }) ?? { line: 0, character: 0 }, + dpqlMockDocumentFields.get(uri) + ); + }, + 'dpql/renderExpression': (msg) => mockRenderDpql(msg.params?.expression as IDpqlMockNode), 'dpql/expressionType': (msg) => mockExpressionTypeDpql(msg.params?.expression as IDpqlMockNode), @@ -182,6 +193,8 @@ export const startDpqlMockLspServer = ( ...DEFAULT_LSP_CAPABILITIES, textDocumentSync: { openClose: true, change: 2 }, completionProvider: { triggerCharacters: ['@', '$', '.', ':'] }, + // the real server answers hovers: functions, operators, fields, accessors, templates (qorus#646) + hoverProvider: true, }, // Unknown requests get an empty success so nothing hangs. defaultResult: {}, diff --git a/src/components/smartEditor/SmartEditor.stories.tsx b/src/components/smartEditor/SmartEditor.stories.tsx index a88b830f..eed650bb 100644 --- a/src/components/smartEditor/SmartEditor.stories.tsx +++ b/src/components/smartEditor/SmartEditor.stories.tsx @@ -8,7 +8,7 @@ // component proper. import { StoryObj } from '@storybook/react-vite'; -import { fn, fireEvent, userEvent, waitFor, within, expect } from 'storybook/test'; +import { fn, userEvent, waitFor, within, expect } from 'storybook/test'; import { sleep } from '../../../__tests__/utils'; import { useState } from 'react'; import { StoryMeta } from '../../types'; @@ -16,6 +16,7 @@ import { createMockLspServer, IMockLspServer, MOCK_LSP_URL } from './__fixtures_ import { SmartEditor } from './SmartEditor'; import { useLspSession } from './useLspSession'; import { withCoarsePointer } from '../../stories/coarsePointer'; +import { middleOf, mouseOffThePage, restMouseOn } from '../../stories/mouse'; import { withTouchScreen } from '../../stories/touchScreen'; import { PHONE_VIEWPORT } from '../../stories/tracking'; @@ -666,65 +667,6 @@ const wordNamingServer = () => }, }); -/** The middle of `word` in the editor's text, on the screen. */ -const middleOf = (editable: HTMLElement, word: string) => { - const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT); - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - const at = node.textContent?.indexOf(word) ?? -1; - if (at >= 0) { - const range = document.createRange(); - range.setStart(node, at + Math.floor(word.length / 2)); - range.setEnd(node, at + Math.floor(word.length / 2) + 1); - const box = range.getBoundingClientRect(); - return { clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }; - } - } - throw new Error(`"${word}" is not in the editor`); -}; - -interface IVitestMouse { - userEvent: { hover(element: Element, options?: unknown): Promise }; - cdp(): { send(method: string, params?: Record): Promise }; -} - -/** The test runner's browser, or none where the play runs in Storybook's UI (see `withTouchScreen`). */ -const vitestBrowser = async (): Promise => { - try { - return (await import('vitest/browser')) as unknown as IVitestMouse; - } catch { - return undefined; - } -}; - -/** - * Rests the mouse on `at` in `element`. In the test runner it is the browser's own mouse, moved there by - * Playwright, whose pointer events say "mouse" as a person's do; in Storybook's UI, where there is none to - * move, a mouse's pointer event at that point. A story that moves the browser's mouse puts it back off the - * page when it ends (`mouseOffThePage`). - */ -const restMouseOn = async (element: HTMLElement, at: { clientX: number; clientY: number }) => { - const browser = await vitestBrowser(); - if (!browser) { - fireEvent.pointerMove(element, { pointerType: 'mouse', ...at }); - return; - } - const box = element.getBoundingClientRect(); - await browser.userEvent.hover(element, { - position: { x: at.clientX - box.left, y: at.clientY - box.top }, - }); -}; - -/** - * The browser's mouse off the page, where it is before anything moves it: nothing is under it. Left where a - * story rested it, it stayed over the stories that came after it in the same browser, and whatever they drew - * there was hovered - a card's title opened its hover, which said the title a second time - * (Tracking/Experiment Card, in CI). - */ -const mouseOffThePage = async () => { - const browser = await vitestBrowser(); - await browser?.cdp().send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: -1, y: -1 }); -}; - /** The hovers shown, as their text. */ const shownHovers = () => Array.from(document.querySelectorAll('.reqore-popover-content')) diff --git a/src/stories/mouse.ts b/src/stories/mouse.ts new file mode 100644 index 00000000..464195f0 --- /dev/null +++ b/src/stories/mouse.ts @@ -0,0 +1,73 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A mouse in a story: resting on a word, and taken off the page when the story ends. Shared by the stories +// of every editor that shows the server's hover (SmartEditor, DpqlEditor). + +import { fireEvent } from 'storybook/test'; + +/** The middle of `word` in the editor's text, on the screen. */ +export const middleOf = (editable: HTMLElement, word: string) => { + const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const at = node.textContent?.indexOf(word) ?? -1; + if (at >= 0) { + const range = document.createRange(); + range.setStart(node, at + Math.floor(word.length / 2)); + range.setEnd(node, at + Math.floor(word.length / 2) + 1); + const box = range.getBoundingClientRect(); + return { clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }; + } + } + throw new Error(`"${word}" is not in the editor`); +}; + +interface IVitestMouse { + userEvent: { hover(element: Element, options?: unknown): Promise }; + cdp(): { send(method: string, params?: Record): Promise }; +} + +/** The test runner's browser, or none where the play runs in Storybook's UI (see `withTouchScreen`). */ +const vitestBrowser = async (): Promise => { + try { + return (await import('vitest/browser')) as unknown as IVitestMouse; + } catch { + return undefined; + } +}; + +/** + * Rests the mouse on `at` in `element`. In the test runner it is the browser's own mouse, moved there by + * Playwright, whose pointer events say "mouse" as a person's do; in Storybook's UI, where there is none to + * move, a mouse's pointer event at that point. A story that moves the browser's mouse puts it back off the + * page when it ends (`mouseOffThePage`). + */ +export const restMouseOn = async ( + element: HTMLElement, + at: { clientX: number; clientY: number } +) => { + const browser = await vitestBrowser(); + if (!browser) { + fireEvent.pointerMove(element, { pointerType: 'mouse', ...at }); + return; + } + const box = element.getBoundingClientRect(); + await browser.userEvent.hover(element, { + position: { x: at.clientX - box.left, y: at.clientY - box.top }, + }); +}; + +/** + * The browser's mouse off the page, where it is before anything moves it: nothing is under it. Left where a + * story rested it, it stayed over the stories that came after it in the same browser, and whatever they drew + * there was hovered - a card's title opened its hover, which said the title a second time + * (Tracking/Experiment Card, in CI). + */ +export const mouseOffThePage = async () => { + const browser = await vitestBrowser(); + await browser?.cdp().send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: -1, y: -1 }); +}; + +/** The middle of `element` on the screen: where a mouse rests to hover it (a chip). */ +export const middleOfElement = (element: Element) => { + const box = element.getBoundingClientRect(); + return { clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }; +}; From a68c62db712607d12488c389946940f27562c839 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 15:59:37 +0200 Subject: [PATCH 175/262] fix(form-engine): Cancel, Escape and Revert put an expression back as one, and a value as a value (qorus#646) David's item 2(b) from the Qorus IDE's round-6 review: Expected Value held the reference $.create.items[*].id; cleared on the Expression tab and then cancelled, its read view said "2 fields", exp and args, and it was stored as that hash, a literal. An undone edit put the field's value and type back and said nothing of the expression flag. Saying nothing, the engine keeps the flag only while the field still holds an expression (shouldMarkAsExpression), and at that moment the field held nothing, or a value: so the expression came back without what marks it as one. restoredFieldChange (argumentPresence.ts) is what every undo puts back: the value, its type and whether it is an expression, said either way. All four undo sites use it: - CompactRow's cancelEdit (Cancel edit and Escape): the snapshot taken when the field opens holds the flag, and a change of the flag alone is something to undo; - CompactRow's revert in the open field, and in the read row; - FormEngine's Revert changes in the row's ... menu (the form that is not compact). Revert all changes puts the loaded fields back whole, flag included, and is unchanged. Stories (Form Engine), each failing before: - Canceling A Cleared Expression Puts It Back; - Escape On A Cleared Expression Puts It Back; - Reverting A Cleared Expression Puts It Back (replaced with 5, Done, then the read row's revert); - Revert In The Editor Puts Back The Expression; - Reverting A Removed Expression Puts It Back (the form that is not compact). Control, passing before and after: Canceling An Edit That Made An Expression Puts Back The Value (the other direction). Unit: __tests__/restoredExpression.test.ts (the three fail when the flag is left unsaid). --- __tests__/restoredExpression.test.ts | 47 ++++ src/components/form/engine/CompactRow.tsx | 27 +- .../form/engine/FormEngine.stories.tsx | 232 ++++++++++++++++++ src/components/form/engine/FormEngine.tsx | 5 +- .../form/expressions/argumentPresence.ts | 19 ++ 5 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 __tests__/restoredExpression.test.ts diff --git a/__tests__/restoredExpression.test.ts b/__tests__/restoredExpression.test.ts new file mode 100644 index 00000000..32dd9693 --- /dev/null +++ b/__tests__/restoredExpression.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + restoredFieldChange, + shouldMarkAsExpression, +} from '../src/components/form/expressions/argumentPresence'; + +/** + * An undone edit puts a field back as it was (qorus#646): Cancel, Escape and Revert. An expression cleared on the + * Expression tab, or replaced with a value, came back from them without what marks it as an expression: the + * engine keeps the flag only while the field still holds an expression when the change says nothing of it, and + * the field held nothing, or a value, at that moment. Its AST was then stored as a literal hash, and its row read + * "2 fields", `exp` and `args`. The FormEngine stories (Canceling A Cleared Expression Puts It Back and the rest) + * undo each way; these check the rule they rely on. + */ +const AST = { + exp: '+', + args: [ + { type: 'int', value: '$record:{pos}' }, + { type: 'int', value: 1 }, + ], +}; + +describe('what a field is put back to', () => { + it('says an expression is one, so the flag is set whatever the field holds now', () => { + const [value, type, isExpression] = restoredFieldChange({ + type: 'int', + value: AST, + is_expression: true, + }); + + expect([value, type, isExpression]).toEqual([AST, 'int', true]); + // the field was cleared (no longer an expression): put back, it is one again + expect(shouldMarkAsExpression(isExpression, value, false)).toBe(true); + }); + + it('says a value is not one, so the flag goes whatever the field holds now', () => { + const [value, type, isExpression] = restoredFieldChange({ type: 'int', value: 12 }); + + expect([value, type, isExpression]).toEqual([12, 'int', false]); + // the field had been made an expression: put back, it is the value + expect(shouldMarkAsExpression(isExpression, value, true)).toBe(false); + }); + + it('puts back a field that held nothing as nothing, not an expression', () => { + expect(restoredFieldChange(undefined)).toEqual([undefined, undefined, false]); + }); +}); diff --git a/src/components/form/engine/CompactRow.tsx b/src/components/form/engine/CompactRow.tsx index 6f7da1f9..21cc7492 100644 --- a/src/components/form/engine/CompactRow.tsx +++ b/src/components/form/engine/CompactRow.tsx @@ -78,6 +78,7 @@ import { } from './readFirst'; import { StructuredDataView } from './_structuredData/StructuredDataView'; import { DpqlRendering } from '../expressions/DpqlRendering'; +import { restoredFieldChange } from '../expressions/argumentPresence'; import { ReqraftColorValue } from '../../colorValue'; import { TFieldWithOwnTemplates } from './rendererTypes'; import { isHoveringPointer } from '../../../helpers/hoveringPointer'; @@ -984,10 +985,16 @@ export const CompactRow = memo( // // The dependency list is `isExpanded` alone on purpose: adding the value // would re-take the snapshot on every keystroke, leaving nothing to cancel. - const editEntryValue = React.useRef<{ value: any; type?: string } | undefined>(undefined); + const editEntryValue = React.useRef< + { value: any; type?: string; is_expression?: boolean } | undefined + >(undefined); React.useEffect(() => { if (isExpanded) { - editEntryValue.current = { value: optionField?.value, type: optionField?.type }; + editEntryValue.current = { + value: optionField?.value, + type: optionField?.type, + is_expression: !!(optionField as { is_expression?: boolean } | undefined)?.is_expression, + }; } else { editEntryValue.current = undefined; } @@ -1010,8 +1017,14 @@ export const CompactRow = memo( // an edit still waiting for its pause is part of what is discarded pendingEdits.discard(); const snapshot = editEntryValue.current; - if (snapshot && !isEqual(snapshot.value, optionField?.value)) { - handleValueChange(optionName, snapshot.value, snapshot.type); + // the field as it was opened: an expression is put back as one, a value as a value + if ( + snapshot && + (!isEqual(snapshot.value, optionField?.value) || + snapshot.is_expression !== + !!(optionField as { is_expression?: boolean } | undefined)?.is_expression) + ) { + handleValueChange(optionName, ...restoredFieldChange(snapshot)); } toggleExpandedOption(optionName); }, [handleValueChange, optionField?.value, optionName, toggleExpandedOption, pendingEdits]); @@ -1098,8 +1111,7 @@ export const CompactRow = memo( e.stopPropagation(); handleValueChange( optionName, - originalValue.current?.[optionName]?.value, - originalValue.current?.[optionName]?.type + ...restoredFieldChange(originalValue.current?.[optionName]) ); }} /> @@ -2687,8 +2699,7 @@ export const CompactRow = memo( e.stopPropagation(); handleValueChange( optionName, - originalValue.current?.[optionName]?.value, - originalValue.current?.[optionName]?.type + ...restoredFieldChange(originalValue.current?.[optionName]) ); }} /> diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index cce06c7d..9ec2b73d 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -11263,6 +11263,238 @@ export const ExpressionWrittenOnExpressionShowsWhenTheTabIsBack: Story = { }, }; +/** Computed as the form was given it: the expression pos + 1. */ +const COMPUTED_EXPRESSION = TEMPLATE_OR_EXPRESSION_VALUE.computed; + +/** Opens Computed (the expression pos + 1) on its Expression tab and clears it with the text's ×: it holds nothing. */ +const clearComputed = async (canvasElement: HTMLElement) => { + const row = await editOption('computed'); + await waitFor(() => expect(optionActiveTab(row)).toBe('expression')); + const clear = await waitFor(() => { + const el = optionRow('computed').querySelector( + '.expression-field .reqore-clear-input-button' + ); + expect(el, "the text's clear button").toBeTruthy(); + return el as HTMLElement; + }); + await userEvent.click(clear); + await waitFor(() => expect(heldOption(canvasElement, 'computed')?.value).toBeUndefined()); +}; + +/** Opens Computed (the expression pos + 1) on Value, replaces it with a value and writes 5 there: it holds 5. */ +const replaceComputedWithFive = async (canvasElement: HTMLElement) => { + const row = await editOption('computed'); + await chooseTab(row, 'value'); + await userEvent.click( + await waitFor(() => { + const el = optionRow('computed').querySelector('.value-tab-replace'); + expect(el, 'Replace it with a value').toBeTruthy(); + return el as HTMLElement; + }) + ); + await waitFor(() => expect(heldOption(canvasElement, 'computed')?.value).toBeUndefined()); + await userEvent.click( + await waitFor(() => { + const el = optionRow('computed').querySelector( + '[data-slate-editor].value-tab-text, .value-tab-text [data-slate-editor]' + ); + expect(el, 'the Value tab is written in').toBeTruthy(); + return el as HTMLElement; + }) + ); + await userEvent.keyboard('5'); + await waitFor( + () => expect(heldOption(canvasElement, 'computed')).toEqual({ type: 'int', value: 5 }), + { + timeout: 10000, + } + ); +}; + +/** Computed holds its expression again, marked as one, and its read row says it - not a hash of exp and args. */ +const expectComputedIsItsExpression = async (canvasElement: HTMLElement) => { + await waitFor(() => expect(heldOption(canvasElement, 'computed')).toEqual(COMPUTED_EXPRESSION)); + await waitFor(() => { + const read = optionRow('computed'); + expect(read.classList.contains('readfirst-row-editing')).toBe(false); + expect(read.textContent).toContain('pos'); + expect(read.textContent).not.toMatch(/\bargs\b|\bexp\b|2 fields/); + }); +}; + +export const CancelingAClearedExpressionPutsItBack: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form, opens Computed (the expression pos + 1), clears it on the Expression tab and cancels the edit: Computed holds pos + 1 again, as an expression, and its row says it. Before, the expression came back without what marks it as one: the row said "2 fields", exp and args, and it was stored as that hash (qorus#646).', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + await clearComputed(canvasElement); + await userEvent.click( + await waitFor(() => { + const el = optionRow('computed').querySelector('.options-readfirst-cancel'); + expect(el, 'Cancel edit, once the field changed').toBeTruthy(); + return el as HTMLElement; + }) + ); + await expectComputedIsItsExpression(canvasElement); + }, +}; + +export const EscapeOnAClearedExpressionPutsItBack: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form, opens Computed (the expression pos + 1), clears it on the Expression tab and presses Escape: as Cancel edit, Computed holds pos + 1 again, as an expression.', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + await clearComputed(canvasElement); + // from the tab the field is open on: a key pressed in the text is the text's first (its list closes on it) + (optionRow('computed').querySelector('.value-tab[aria-pressed="true"]') as HTMLElement).focus(); + await userEvent.keyboard('{Escape}'); + await expectComputedIsItsExpression(canvasElement); + }, +}; + +export const RevertingAClearedExpressionPutsItBack: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form, opens Computed (the expression pos + 1), replaces it on Value with 5 and closes it with Done, then reverts it from its row: Computed holds pos + 1 again, as an expression, and its row says it. Before, it came back as a hash of exp and args.', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + await replaceComputedWithFive(canvasElement); + await userEvent.click( + optionRow('computed').querySelector('.options-readfirst-done') as HTMLElement + ); + await userEvent.click( + await waitFor(() => { + const el = optionRow('computed').querySelector('.options-readfirst-revert'); + expect(el, "the read row's revert").toBeTruthy(); + return el as HTMLElement; + }) + ); + await expectComputedIsItsExpression(canvasElement); + }, +}; + +export const RevertInTheEditorPutsBackTheExpression: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form, replaces Computed (the expression pos + 1) on Value with 5 and closes it with Done, then opens it again and reverts it there: the revert of an open field takes it back to the expression the form was given, marked as one.', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + await replaceComputedWithFive(canvasElement); + await userEvent.click( + optionRow('computed').querySelector('.options-readfirst-done') as HTMLElement + ); + await waitFor(() => + expect(optionRow('computed').classList.contains('readfirst-row-editing')).toBe(false) + ); + const row = await editOption('computed'); + await userEvent.click( + await waitFor(() => { + const el = row.querySelector('.options-readfirst-revert'); + expect(el, "the open field's revert").toBeTruthy(); + return el as HTMLElement; + }) + ); + await waitFor(() => expect(heldOption(canvasElement, 'computed')).toEqual(COMPUTED_EXPRESSION)); + }, +}; + +export const CancelingAnEditThatMadeAnExpressionPutsBackTheValue: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form, opens Quantity (12) on Value and writes " + 2", which makes it an expression, then cancels the edit: Quantity holds 12 again, a value, not marked as an expression.', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + const row = await editOption('quantity'); + await userEvent.click( + await waitFor(() => { + const el = row.querySelector('.value-tab-text [data-slate-editor]'); + expect(el).toBeTruthy(); + return el as HTMLElement; + }) + ); + await userEvent.keyboard(' + 2'); + await waitFor(() => expect(heldOption(canvasElement, 'quantity')?.is_expression).toBe(true), { + timeout: 10000, + }); + await userEvent.click( + await waitFor(() => { + const el = optionRow('quantity').querySelector('.options-readfirst-cancel'); + expect(el, 'Cancel edit').toBeTruthy(); + return el as HTMLElement; + }) + ); + await waitFor(() => + expect(heldOption(canvasElement, 'quantity')).toEqual({ type: 'int', value: 12 }) + ); + }, +}; + +/** Computed (the expression pos + 1) alone, in the form that is not compact, and what the form holds for it. */ +const ClassicExpressionForm = () => { + const [value, setValue] = useState({ computed: COMPUTED_EXPRESSION }); + return ( + <> + setValue(v)} + /> + + + ); +}; + +export const RevertingARemovedExpressionPutsItBack: Story = { + parameters: { + docs: { + description: { + story: + "Renders Computed (the expression pos + 1) in the form that is not compact, removes its value and reverts it from its row's ⋯ menu: Computed holds pos + 1 again, as an expression.", + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + await openOptionRowMenu(0); + await _testsClickButton({ selector: '.options-item-remove', nth: 0 }); + await waitFor(() => expect(heldOption(canvasElement, 'computed')?.value).toBeUndefined()); + await openOptionRowMenu(0); + await _testsClickButton({ selector: '.options-item-revert', nth: 0 }); + await waitFor(() => expect(heldOption(canvasElement, 'computed')).toEqual(COMPUTED_EXPRESSION)); + }, +}; + /** A host's summary for an editor that reports a status: field mappings, set but incomplete, or not set. */ const MappingStatusSummary = ({ value }: { value: unknown }) => { const set = Array.isArray(value) && value.length > 0; diff --git a/src/components/form/engine/FormEngine.tsx b/src/components/form/engine/FormEngine.tsx index d2c6d8f1..7c4739c1 100644 --- a/src/components/form/engine/FormEngine.tsx +++ b/src/components/form/engine/FormEngine.tsx @@ -46,7 +46,7 @@ import { TQorusFormOperatorValue, TQorusType, } from '@qoretechnologies/ts-toolkit'; -import { shouldMarkAsExpression } from '../expressions/argumentPresence'; +import { restoredFieldChange, shouldMarkAsExpression } from '../expressions/argumentPresence'; import { offersTypeChoices } from './typeChoices'; import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions'; import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes'; @@ -4969,8 +4969,7 @@ const FormEngineImpl = ({ onClick: () => { handleValueChange( optionName, - originalValue.current?.[optionName]?.value, - originalValue.current?.[optionName]?.type + ...restoredFieldChange(originalValue.current?.[optionName]) ); }, }, diff --git a/src/components/form/expressions/argumentPresence.ts b/src/components/form/expressions/argumentPresence.ts index ddc41681..c4840653 100644 --- a/src/components/form/expressions/argumentPresence.ts +++ b/src/components/form/expressions/argumentPresence.ts @@ -127,6 +127,25 @@ export const shouldMarkAsExpression = ( return !!wasExpression && looksLikeExpressionAst(value); }; +/** + * What `handleValueChange` puts a field back to when an edit is undone (Cancel, Escape, Revert): its value, the + * type it is stored as, and whether it is an expression - said either way, never left as no opinion. + * + * Left as no opinion, the flag was kept only while the field still held an expression + * (`shouldMarkAsExpression`). An expression put back over a field that had been cleared, or replaced with a + * value, came back without it: its AST was stored as a literal hash, and its row read "2 fields", `exp` and + * `args` (qorus#646). + */ +export const restoredFieldChange = (field?: { + value?: unknown; + type?: string; + is_expression?: boolean; +}): [value: unknown, type: string | undefined, isExpression: boolean] => [ + field?.value, + field?.type, + !!field?.is_expression, +]; + /** * Fill in an expression's missing arguments, leaving the supplied ones alone. * From b9b8891db2bd11dbb2fa24297a1a4c69d1a5a977 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sun, 18 Oct 2026 15:59:46 +0200 Subject: [PATCH 176/262] fix(template-field): the Value tab draws an expression that is one template as that template's chip (qorus#646) David's item 1 from the Qorus IDE's round-6 review: a capture, a field of the case or of the test picked on the Value tab of Expected Value showed "This expression can't be shown as a value." The IDE holds such a reference as value($record:{...}), so that it opens on the Expression tab, and the Value tab refused every expression, although this one is exactly that template. templateOfExpression (writtenAsText.ts) reads the template an expression is when it is nothing but that template: value(...) or template(...) with one template as its one argument (a string, a value, or the parser's {raw}). The Value tab shows it as that template: the template's chip in a field written as text, the template's note in one with its own control (as for a template held bare). Any other expression is still "can't be shown as a value". Looking at it changes nothing it holds; a value written or picked on the Value tab is that value, as on any Value tab, and a field picked into its text goes where the caret is. loneTemplateOf (the Text view's lone template(...), stored bare) shares the reading and is unchanged. Stories (Form Engine), both failing before ("can't be shown"): - Value Tab Draws A Referenced Field As Its Chip: value($record:{bezeichnung}) and template($record:{pos}), each its chip on Value; - Value Tab Pick Replaces A Referenced Field: emptied with its x, pos picked on Value is stored as $record:{pos}. Unit: __tests__/expressionTemplateOnValueTab.test.ts (fails when only template(...) is read). --- .../expressionTemplateOnValueTab.test.ts | 71 +++++++++ .../form/engine/FormEngine.stories.tsx | 138 ++++++++++++++++++ .../form/fields/template/TemplateField.tsx | 10 +- .../form/fields/template/writtenAsText.ts | 30 +++- 4 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 __tests__/expressionTemplateOnValueTab.test.ts diff --git a/__tests__/expressionTemplateOnValueTab.test.ts b/__tests__/expressionTemplateOnValueTab.test.ts new file mode 100644 index 00000000..bb01fc92 --- /dev/null +++ b/__tests__/expressionTemplateOnValueTab.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + loneTemplateOf, + templateOfExpression, +} from '../src/components/form/fields/template/writtenAsText'; + +/** + * The Value tab shows an expression that is nothing but one template as that template's chip (qorus#646): a host + * may hold a reference to a field as `value($record:{…})` - the Qorus IDE reads a test's reference back that way, + * so that it opens on the Expression tab - and the Value tab said "This expression can't be shown as a value." for + * every one of them. The FormEngine stories (Value Tab Draws A Referenced Field As Its Chip, Value Tab Pick + * Replaces A Referenced Field) draw it; these check which expressions are such a template. + */ +const AST = { + exp: '+', + args: [ + { type: 'int', value: '$record:{pos}' }, + { type: 'int', value: 1 }, + ], +}; + +describe('the template an expression is', () => { + it('is the one template of value() or template(), as a host may hold a reference', () => { + expect( + templateOfExpression({ + exp: 'value', + args: [{ type: 'string', value: '$record:{bezeichnung}' }], + }) + ).toBe('$record:{bezeichnung}'); + expect( + templateOfExpression({ exp: 'template', args: [{ type: 'int', value: '$record:{pos}' }] }) + ).toBe('$record:{pos}'); + // the argument as the parser gives it, and as a bare string + expect( + templateOfExpression({ exp: 'template', args: [{ value: { raw: '$record:{pos}' } }] }) + ).toBe('$record:{pos}'); + expect(templateOfExpression({ exp: 'value', args: ['$local:{count}'] })).toBe('$local:{count}'); + }); + + it('is nothing for any other expression', () => { + // an operation of its own + expect(templateOfExpression(AST)).toBeUndefined(); + // a value that is not a template, or text around one + expect( + templateOfExpression({ exp: 'value', args: [{ type: 'int', value: 12 }] }) + ).toBeUndefined(); + expect( + templateOfExpression({ exp: 'value', args: [{ type: 'string', value: 'SUP-$record:{pos}' }] }) + ).toBeUndefined(); + // more than one argument, or none + expect( + templateOfExpression({ + exp: 'value', + args: [{ value: '$record:{pos}' }, { value: '$record:{pos}' }], + }) + ).toBeUndefined(); + expect(templateOfExpression({ exp: 'value', args: [] })).toBeUndefined(); + // not an expression at all + expect(templateOfExpression('$record:{pos}')).toBeUndefined(); + expect(templateOfExpression(undefined)).toBeUndefined(); + }); + + it('leaves the Text view reading only template() as the lone template it stores bare', () => { + expect( + loneTemplateOf({ value: { exp: 'template', args: [{ value: { raw: '$record:{pos}' } }] } }) + ).toBe('$record:{pos}'); + expect( + loneTemplateOf({ value: { exp: 'value', args: [{ value: '$record:{pos}' }] } }) + ).toBeUndefined(); + }); +}); diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index 9ec2b73d..f5eda0c0 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -11495,6 +11495,144 @@ export const RevertingARemovedExpressionPutsItBack: Story = { }, }; +/** Options each holding a field of the row as an expression that is that one field: value(...) and template(...). */ +const REFERENCE_OPTIONS = { + by_value: { + type: 'string', + display_name: 'By value', + supports_templates: true, + supports_expressions: true, + expressions: mockExpressions, + }, + by_template: { + type: 'int', + display_name: 'By template', + supports_templates: true, + supports_expressions: true, + expressions: mockExpressions, + }, +} as unknown as IQorusFormSchema; + +const REFERENCE_VALUE = { + by_value: { + type: 'string', + is_expression: true, + value: { exp: 'value', args: [{ type: 'string', value: '$record:{bezeichnung}' }] }, + }, + by_template: { + type: 'int', + is_expression: true, + value: { exp: 'template', args: [{ type: 'int', value: '$record:{pos}' }] }, + }, +}; + +/** The form holding the two references, and what it holds. */ +const ReferenceForm = () => { + const [value, setValue] = useState(REFERENCE_VALUE); + return ( + <> + setValue(v)} + /> + + + ); +}; + +/** The chips the Value tab's text holds. */ +const valueTabChips = (field: string) => + [...(optionRow(field)?.querySelectorAll('.value-tab-text .reqore-tag') ?? [])].map( + (chip) => chip.textContent?.trim() + ); + +export const ValueTabDrawsAReferencedFieldAsItsChip: Story = { + parameters: { + docs: { + description: { + story: + 'Renders a form whose options each hold a field of the row as an expression that is that one field - value($record:{bezeichnung}) and template($record:{pos}), as a host may hold a reference. Each opens on Expression; on Value it is that field\'s chip, not "can\'t be shown as a value" (qorus#646). Looking at it keeps value(...) as it is; template(...) is stored as the bare template once the Expression tab is left, as the Expression tab stores a lone template.', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + for (const [field, name, held] of [ + ['by_value', 'bezeichnung', REFERENCE_VALUE.by_value], + ['by_template', 'pos', { type: 'int', value: '$record:{pos}' }], + ] as const) { + const row = await editOption(field); + await waitFor(() => expect(optionActiveTab(row)).toBe('expression')); + await chooseTab(row, 'value'); + await waitFor(() => expect(valueTabChips(field)).toEqual([expect.stringContaining(name)])); + expect(optionRow(field).querySelector('.value-tab-cannot-show')).toBeNull(); + // and back on Expression it is the same field + await chooseTab(optionRow(field), 'expression'); + await chooseTab(optionRow(field), 'value'); + await waitFor(() => expect(valueTabChips(field)).toEqual([expect.stringContaining(name)])); + expect(heldOption(canvasElement, field)).toEqual(held); + await userEvent.click( + optionRow(field).querySelector('.options-readfirst-done') as HTMLElement + ); + } + }, +}; + +export const ValueTabPickReplacesAReferencedField: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form holding value($record:{bezeichnung}), opens it on Value, empties the text with its × and picks pos from the fields the text offers: it holds the field pos, a value written on the Value tab, and its chip says pos. A field picked into the text goes where the caret is, as into any text.', + }, + }, + }, + render: () => , + play: async ({ canvasElement }) => { + const row = await editOption('by_value'); + await chooseTab(row, 'value'); + await waitFor(() => + expect(valueTabChips('by_value')).toEqual([expect.stringContaining('bezeichnung')]) + ); + await userEvent.click( + await waitFor(() => { + const el = optionRow('by_value').querySelector( + '.value-tabs-field .reqore-clear-input-button' + ); + expect(el, "the text's clear button").toBeTruthy(); + return el as HTMLElement; + }) + ); + await waitFor(() => expect(heldOption(canvasElement, 'by_value')?.value).toBeUndefined()); + await userEvent.click( + optionRow('by_value').querySelector( + '.value-tab-text [data-slate-editor], [data-slate-editor].value-tab-text' + ) as HTMLElement + ); + const field = await waitFor(() => { + const el = [ + ...document.querySelectorAll('.reqore-popover-content .reqore-menu-item'), + ].find((item) => item.textContent?.startsWith('pos')); + expect(el, 'the fields on offer').toBeTruthy(); + return el as HTMLElement; + }); + await userEvent.click(field); + await waitFor(() => + expect(heldOption(canvasElement, 'by_value')).toEqual({ + type: 'string', + value: '$record:{pos}', + }) + ); + await waitFor(() => + expect(valueTabChips('by_value')).toEqual([expect.stringContaining('pos')]) + ); + }, +}; + /** A host's summary for an editor that reports a status: field mappings, set but incomplete, or not set. */ const MappingStatusSummary = ({ value }: { value: unknown }) => { const set = Array.isArray(value) && value.length > 0; diff --git a/src/components/form/fields/template/TemplateField.tsx b/src/components/form/fields/template/TemplateField.tsx index 658793f2..be907e28 100644 --- a/src/components/form/fields/template/TemplateField.tsx +++ b/src/components/form/fields/template/TemplateField.tsx @@ -79,6 +79,7 @@ import { sameValueType, valueTextOf, TValueTab, + templateOfExpression, templateTextValue, untypedTextOf, } from './writtenAsText'; @@ -854,6 +855,9 @@ const TemplateFieldImpl = memo( /** The value is an expression (its AST), as the host says. */ const valueIsExpression = !!(isFunction || isDefaultFunction) && value !== null && typeof value === 'object'; + /** An expression that is nothing but one template (`value($record:{pos})`, as a host may hold a reference to + * a field): the Value tab shows it as that template, where it can show no other expression (qorus#646). */ + const expressionTemplate = valueIsExpression ? templateOfExpression(value) : undefined; /** Where a saved value opens: an expression on Expression; a template on Value where it is written as * text, else on Expression (or Template, for a template-only field); anything else on Value. */ const [tab, setTab] = useState(() => { @@ -2065,7 +2069,7 @@ const TemplateFieldImpl = memo( /* A lone template of another type than the field's is converted when the value is used: said as a warning, not a block - whether it converts depends on the data (qorus#646, David). */ const loneTemplate = - typeof value === 'string' && isCompleteTemplateToken(value) ? value : undefined; + typeof value === 'string' && isCompleteTemplateToken(value) ? value : expressionTemplate; const loneTemplateType = loneTemplate && !typeIsAnyLike ? findTemplate(templates ?? {}, loneTemplate)?.badge @@ -2099,7 +2103,7 @@ const TemplateFieldImpl = memo( ); const valueBody = () => { - if (valueIsExpression) { + if (valueIsExpression && expressionTemplate === undefined) { return cannotShow(conditionField ? words.cannotShowCondition : words.cannotShowExpression); } if (valueTabIsText) { @@ -2115,7 +2119,7 @@ const TemplateFieldImpl = memo( singleLine={isSingleLineStringType('string')} value={ typeof templateValue === 'string' ? templateValue : ( - valueTextOf(value, type as string) + (expressionTemplate ?? valueTextOf(value, type as string)) ) } templates={filteredTemplates} diff --git a/src/components/form/fields/template/writtenAsText.ts b/src/components/form/fields/template/writtenAsText.ts index 3fa5ec4b..e574feb7 100644 --- a/src/components/form/fields/template/writtenAsText.ts +++ b/src/components/form/fields/template/writtenAsText.ts @@ -89,18 +89,34 @@ export const expressionTextOfValue = (value: unknown): string => { return `concat(${parts.map((part) => (isCompleteTemplateToken(part) ? part : JSON.stringify(part))).join(', ')})`; }; +/** The template a node is when it is one of `operations` with one template as its one argument: the + * argument as a string, as a value (`{value: "$record:{pos}"}`) or as the parser gives it (`{value: {raw}}`). */ +const templateOperandOf = (node: unknown, operations: string[]): string | undefined => { + const { exp, args } = (node ?? {}) as { exp?: unknown; args?: unknown[] }; + if (typeof exp !== 'string' || !operations.includes(exp) || args?.length !== 1) return undefined; + const arg = args[0] as { value?: { raw?: unknown } | string } | string | undefined; + const raw = + typeof arg === 'string' ? arg + : typeof arg?.value === 'string' ? arg.value + : arg?.value?.raw; + return typeof raw === 'string' && isCompleteTemplateToken(raw) ? raw : undefined; +}; + /** * A lone template written in the expression editor's Text view, as the server parses it: the `template` * operation with the reference as its one argument. It is stored as the bare template, which every consumer * evaluates; the expression form of it is not one the server reads back as the template's value. */ -export const loneTemplateOf = (expression: unknown): string | undefined => { - const node = (expression as { value?: { exp?: string; args?: unknown[] } } | undefined)?.value; - if (node?.exp !== 'template' || node.args?.length !== 1) return undefined; - const arg = node.args[0] as { value?: { raw?: unknown } | string } | undefined; - const raw = typeof arg?.value === 'string' ? arg.value : arg?.value?.raw; - return typeof raw === 'string' && isCompleteTemplateToken(raw) ? raw : undefined; -}; +export const loneTemplateOf = (expression: unknown): string | undefined => + templateOperandOf((expression as { value?: unknown } | undefined)?.value, ['template']); + +/** + * The template an expression is when it is nothing but that template: `value($record:{pos})` or + * `template($record:{pos})`, as a host may hold a reference to a field (the Qorus IDE reads a test's reference + * back as `value(...)`, qorus#646). The Value tab shows it as that template. Undefined for any other expression. + */ +export const templateOfExpression = (expression: unknown): string | undefined => + templateOperandOf(expression, ['value', 'template']); /** * A lone value written in the expression editor's Text view (`12`, `true`, `"open"`), as the server parses From 44c9a2bfa9ac075e895080fe823aa7d1838acf7d Mon Sep 17 00:00:00 2001 From: David Nichols Date: Mon, 19 Oct 2026 12:39:19 +0200 Subject: [PATCH 177/262] test(stories): the stories' server writes a record field as the Qorus server does: @pos, @"create.order-id", @orders[*].id (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stand-in for the Qorus language server wrote a record field as its template: an expression holding $record:{pos} was serialized as "$record:{pos} + 1", which is not what the Expression tab shows against a live server. Qorus reads $record:{} as a test path (QorusRecordPath::reference()) and gives it as a field reference, or the accessors over one, and dpql/serialize writes those with the DPQL serializer's accessor sugar and qore#5480's path rule. dpqlMockRecordPath.ts does the same: - a field path whose parts are all bare names is written bare (@create.status), any other quoted as one name (@"create.order-id"): dots separate its parts in a quoted name too, and a dot that is part of a key is \. (@"Unit\.Price"); - after [*] the item's path is .name (@orders[*].id), or {"key"} for one key that is not a name; after {*} it is the path after the value (@prices{*}.amount); a key after an index is .name or {key} (@items[0].sku); - a path the serializer cannot write as accessors stays the template. The stand-in's parser reads all of these back to the same template, so an expression round-trips. The readable rendering (dpql/renderExpression) keeps the template, as the server's does. What changes in the stories: the Expression tab of a saved expression with a record field (FormEngine · Saved Expression Opens On Expression, its phone story, Lists Look And Behave Alike) says @pos where it said $record:{pos}; ExpressionField's eight Visual stories that wait for the server to read the built condition wait for the text it now writes (@n + 1, @quantity + 1, ...). Unit: __tests__/dpqlMockRecordPath.test.ts (the writing, the reading, and the round trip through the stand-in; the two round-trip cases fail with the template printed as before). --- __tests__/dpqlMockRecordPath.test.ts | 116 +++++++ .../expressions/ExpressionField.stories.tsx | 12 +- .../form/expressions/dpqlMockLanguage.ts | 19 +- .../form/expressions/dpqlMockRecordPath.ts | 317 ++++++++++++++++++ 4 files changed, 456 insertions(+), 8 deletions(-) create mode 100644 __tests__/dpqlMockRecordPath.test.ts create mode 100644 src/components/form/expressions/dpqlMockRecordPath.ts diff --git a/__tests__/dpqlMockRecordPath.test.ts b/__tests__/dpqlMockRecordPath.test.ts new file mode 100644 index 00000000..e4788df1 --- /dev/null +++ b/__tests__/dpqlMockRecordPath.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + mockParseDpql, + mockRenderDpql, + mockSerializeDpql, +} from '../src/components/form/expressions/dpqlMockLanguage'; +import { + readRecordReference, + recordReferenceDpql, +} from '../src/components/form/expressions/dpqlMockRecordPath'; + +/** + * The stories' stand-in for the Qorus language server writes a record field as the server does (qorus#646): Qorus + * reads `$record:{}` as a test path and gives it as a field reference, or the accessors over one + * (`QorusRecordPath::reference()`), and the DPQL serializer writes those with its accessor sugar and qore#5480's path + * rule: dots separate the parts of a name, quoted or bare, and a dot that is part of a key is `\.`. It wrote the + * template (`$record:{pos} + 1`), which is not what a user sees on the Expression tab. + */ +const WRITTEN: Array<[string, string]> = [ + ['$record:{pos}', '@pos'], + ['$record:{create.status}', '@create.status'], + ['$record:{menge_bestätigt}', '@menge_bestätigt'], + // a part that is not a name: the path is one quoted name, its dots still separating its parts + ['$record:{create.order-id}', '@"create.order-id"'], + ['$record:{a b}', '@"a b"'], + // a dot that is part of a key (qore#5480) + ['$record:{Unit\\.Price}', '@"Unit\\.Price"'], + // the accessors, as the serializer writes them + ['$record:{orders[*].id}', '@orders[*].id'], + ['$record:{create.items[*].id}', '@create.items[*].id'], + ['$record:{orders[*]}', '@orders[*]'], + ['$record:{orders[*].order-id}', '@orders[*].order-id'], + ['$record:{orders[*].a b}', '@orders[*]{"a b"}'], + ['$record:{prices{*}}', '@prices{*}'], + ['$record:{prices{*}.amount}', '@prices{*}.amount'], + ['$record:{items[0].sku}', '@items[0].sku'], + ['$record:{items[0].a b}', '@items[0]{"a b"}'], + ['$record:{items[0][1]}', '@items[0][1]'], +]; + +describe('a record field written by the stories’ server', () => { + it.each(WRITTEN)('%s is written %s', (template, dpql) => { + expect(recordReferenceDpql(template)).toBe(dpql); + }); + + it('reads the template escapes of the braces first', () => { + expect(recordReferenceDpql('$record:{prices\\{*\\}.amount}')).toBe('@prices{*}.amount'); + }); + + it('leaves what is not a record field, and a path the accessors cannot give', () => { + expect(recordReferenceDpql('$local:name')).toBeUndefined(); + expect(recordReferenceDpql('$data:{create.status}')).toBeUndefined(); + expect(recordReferenceDpql('plain text')).toBeUndefined(); + expect(recordReferenceDpql(12)).toBeUndefined(); + // it does not start with a field, or a list selector is neither * nor an index + expect(recordReferenceDpql('$record:{[0]}')).toBeUndefined(); + expect(recordReferenceDpql('$record:{orders[x]}')).toBeUndefined(); + }); +}); + +describe('a record field read by the stories’ server', () => { + it.each(WRITTEN)('%s is read back from %s', (template, dpql) => { + expect(readRecordReference(`${dpql} == 1`)).toEqual({ template, length: dpql.length }); + }); + + it('stops where the reference ends', () => { + expect(readRecordReference('@pos + 1')).toEqual({ template: '$record:{pos}', length: 4 }); + expect(readRecordReference('@items[0].sku, 2')).toEqual({ + template: '$record:{items[0].sku}', + length: '@items[0].sku'.length, + }); + }); + + it('reads a key given in braces as a part of the path', () => { + expect(readRecordReference('@headers{"content-type"}')?.template).toBe( + '$record:{headers.content-type}' + ); + expect(readRecordReference('@headers{"a.b"}')?.template).toBe('$record:{headers.a\\.b}'); + }); + + it('is nothing where the text does not start with a field', () => { + expect(readRecordReference('pos')).toBeUndefined(); + expect(readRecordReference('@')).toBeUndefined(); + }); +}); + +describe('an expression holding a record field', () => { + const POS_PLUS_ONE = { + exp: '+', + args: [ + { type: 'int', value: '$record:{pos}' }, + { type: 'int', value: 1 }, + ], + }; + + it('is written with the field reference, and read back to the same expression', () => { + const { dpql } = mockSerializeDpql(POS_PLUS_ONE); + + expect(dpql).toBe('@pos + 1'); + expect(mockSerializeDpql(mockParseDpql(dpql).expression).dpql).toBe('@pos + 1'); + }); + + it('round-trips a quoted path and an accessor chain', () => { + for (const text of [ + '@"create.order-id" == "A-1"', + 'size(@orders[*].id) > 0', + '@"Unit\\.Price" > 10', + ]) { + expect(mockSerializeDpql(mockParseDpql(text).expression).dpql).toBe(text); + } + }); + + it('is rendered with the field as its chip', () => { + expect(mockRenderDpql(POS_PLUS_ONE).rendered).toBe('$record:{pos} + 1'); + }); +}); diff --git a/src/components/form/expressions/ExpressionField.stories.tsx b/src/components/form/expressions/ExpressionField.stories.tsx index 65b59081..6002bbd1 100644 --- a/src/components/form/expressions/ExpressionField.stories.tsx +++ b/src/components/form/expressions/ExpressionField.stories.tsx @@ -1950,7 +1950,7 @@ export const VisualOperandsThatCombine: Story = { // asked and answered: the server wrote the expression and read it back await waitFor( () => - expect(dpqlMockParseCalls.some((call) => call.text === '$record:{quantity} + 1')).toBe(true), + expect(dpqlMockParseCalls.some((call) => call.text === '@quantity + 1')).toBe(true), { timeout: 10000 } ); expect(canvasElement.querySelector('.expression-server-errors')).toBeNull(); @@ -2042,7 +2042,7 @@ export const VisualConditionSaysTheServersWords: Story = { }, }, play: async ({ canvasElement }) => { - await readBuiltCondition('$record:{n} + 1'); + await readBuiltCondition('@n + 1'); await waitFor(() => expect(wholeConditionVerdict(canvasElement)).toHaveTextContent(SERVER_TRUTHINESS), { timeout: 10000, }); @@ -2067,7 +2067,7 @@ export const VisualTextConditionSaysTheServersWords: Story = { }, }, play: async ({ canvasElement }) => { - await readBuiltCondition('$record:{name} + 1'); + await readBuiltCondition('@name + 1'); await waitFor( () => expect(wholeConditionVerdict(canvasElement)).toHaveTextContent(SERVER_NOT_A_CONDITION), { timeout: 10000 } @@ -2118,7 +2118,7 @@ export const VisualConditionWithANumberPart: Story = { }, }, play: async ({ canvasElement }) => { - await readBuiltCondition('$record:{n} + 1 && $record:{n} >= 1'); + await readBuiltCondition('@n + 1 && @n >= 1'); await waitFor(() => expect(partVerdicts(canvasElement)).toEqual(['warning']), { timeout: 10000, }); @@ -2149,7 +2149,7 @@ export const VisualConditionWithATextPart: Story = { }, }, play: async ({ canvasElement }) => { - await readBuiltCondition('$record:{name} + 1 || $record:{n} >= 1'); + await readBuiltCondition('@name + 1 || @n >= 1'); await waitFor(() => expect(partVerdicts(canvasElement)).toEqual(['error']), { timeout: 10000 }); expect( canvasElement.querySelector('.expression-condition-part-verdict')?.textContent @@ -2177,7 +2177,7 @@ export const VisualConditionWithPartsThatAreTrueOrFalse: Story = { }, }, play: async ({ canvasElement }) => { - await readBuiltCondition('$record:{n} >= 1 && $record:{n} < 10'); + await readBuiltCondition('@n >= 1 && @n < 10'); expect(partVerdicts(canvasElement)).toEqual([]); expect(errorsShown(canvasElement)).toEqual([]); }, diff --git a/src/components/form/expressions/dpqlMockLanguage.ts b/src/components/form/expressions/dpqlMockLanguage.ts index b84856fd..67dbaa6f 100644 --- a/src/components/form/expressions/dpqlMockLanguage.ts +++ b/src/components/form/expressions/dpqlMockLanguage.ts @@ -15,6 +15,7 @@ import { conditionErrorText, } from '../../../helpers/expressions'; import { getQorusTypes, qorusTypeSentenceForm } from '../../../hooks/useQorusTypes'; +import { readRecordReference, recordReferenceDpql } from './dpqlMockRecordPath'; import { mockExpressions } from './mockExpressions'; import { IExpressionSchema } from './types'; @@ -260,6 +261,14 @@ const tokenize = (text: string): TToken[] => { LEX_RE.lastIndex = 0; while (LEX_RE.lastIndex < text.length) { const start = LEX_RE.lastIndex; + // a record field reference, with its quotes and accessors: the template the server gives it + const at = start + (/^\s*/.exec(text.slice(start))?.[0].length ?? 0); + const reference = text[at] === '@' ? readRecordReference(text.slice(at)) : undefined; + if (reference) { + tokens.push({ kind: 'template', text: reference.template, start: at }); + LEX_RE.lastIndex = at + reference.length; + continue; + } const m = LEX_RE.exec(text); if (!m) { if (text.slice(start).trim() === '') break; @@ -733,10 +742,16 @@ const TEMPLATE_TOKEN = /^\$[A-Za-z_-][\w-]*:[\w.{}]+$/; const argumentValue = (arg: IDpqlMockNode | undefined): unknown => arg && !arg.exp && !arg.is_expression ? arg.value : arg; -const printLiteral = (value: unknown): string => { +/** `readable`: a rendering, which keeps a reference as its template (a chip); else DPQL, as `dpql/serialize` writes it. */ +const printLiteral = (value: unknown, readable = true): string => { if (value && typeof value === 'object' && 'raw' in value) { return String((value as { raw?: string }).raw); } + // a record field is the field reference the server reads it as: @pos, @"create.order-id", @orders[*].id + const reference = readable ? undefined : recordReferenceDpql(value); + if (reference !== undefined) { + return reference; + } if (typeof value === 'string') { // A value that is one template is the template, as the UI form evaluates it and as the server serializes // it (qorus#646); any other string is a string. @@ -759,7 +774,7 @@ const printArg = (arg: IDpqlMockNode, parentPrecedence: number, readable: boolea return precedence !== undefined && precedence < parentPrecedence ? `(${printed})` : printed; } if (arg.exp) return printNode(arg, readable); - return printLiteral(arg.value); + return printLiteral(arg.value, readable); }; /** `render_template` substitution, in the server's order. */ diff --git a/src/components/form/expressions/dpqlMockRecordPath.ts b/src/components/form/expressions/dpqlMockRecordPath.ts new file mode 100644 index 00000000..bda07b49 --- /dev/null +++ b/src/components/form/expressions/dpqlMockRecordPath.ts @@ -0,0 +1,317 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A record field as the stories' stand-in for the Qorus language server writes it in DPQL, and reads it back +// (qorus#646). The server reads `$record:{}` as a test path (Qorus `QorusRecordPath::reference()`): fields +// separated by `.`, the selectors `[n]`, `[*]` and `{*}`, and a backslash escaping the character after it. A path of +// fields is a field reference, a path with a selector the accessors DPQL gives the same path written after `@`; and +// `dpql/serialize` writes those as the DPQL serializer writes them (Qore `DpqlSerializer`, qore#5480): +// +// - a field path whose every part is a bare name is written bare (`@create.status`), any other quoted as one name +// (`@"create.order-id"`): dots separate the parts in a quoted name too, and a dot that is part of a key is `\.` +// (the key `Unit.Price` is `@"Unit\.Price"`); +// - after `[*]` the item's path is `.name` (`@orders[*].id`), or `{"key"}` for one key that is not a name; after +// `{*}` it is the path after the value (`@prices{*}.amount`); +// - a key after an index is `.name` when it is a name (`@items[0].sku`), else `{key}` / `{"key"}`. +// +// What the serializer cannot write as accessors (a wildcard body it writes with `map`) is left as the template. +// Pure: no React, no transport. + +/** Qore `DpqlTokenizer::isBareFieldName()`: a name, then `.` and more parts of letters, digits and `_`. */ +const BARE_FIELD = /^[\p{L}_][\p{L}\p{M}\p{N}_]*(?:\.[\p{L}\p{M}\p{N}_]+)*$/u; +/** Qore `DpqlSerializer::isBareMemberName()`: a key written `.name` after an accessor. */ +const BARE_MEMBER = /^[\p{L}_](?:[\p{L}\p{M}\p{N}_]|-[a-zA-Z])*$/u; +/** Qore `DpqlTokenizer::isBareKey()`: a key written bare in braces. */ +const BARE_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +/** Qore `DpqlKeywordIdentifiers`: identifiers that are values, so never a bare key. */ +const KEYWORD_IDENTIFIERS = new Set(['true', 'false', 'null']); + +const isBareMember = (name: string) => + BARE_MEMBER.test(name) && !KEYWORD_IDENTIFIERS.has(name.toLowerCase()); +const isBareKey = (name: string) => + BARE_KEY.test(name) && !KEYWORD_IDENTIFIERS.has(name.toLowerCase()); + +/** A part of a record path. */ +type TPathPart = + | { kind: 'field'; name: string } + | { kind: 'index'; index: number } + | { kind: 'list' } + | { kind: 'hash' }; + +/** The reference a record path is, as the server builds it: a field reference, or accessors over one. */ +type TReference = + | { kind: 'field'; field: string } + | { kind: 'item' } + | { kind: 'index'; base: TReference; index: number } + | { kind: 'key'; base: TReference; key: string } + | { kind: 'list' | 'hash'; base: TReference; body: TReference }; + +/** The text in the braces of a template with the template's escapes (`\{`, `\}`, `\\`) decoded. */ +export const recordTemplatePath = (text: string): string => text.replace(/\\([{}\\])/g, '$1'); + +/** The text in the braces of a template that decodes to `path`: a backslash before `{`, `}` or `\` is doubled. */ +const recordTemplateText = (path: string): string => path.replace(/\\(?=[{}\\])/g, '\\\\'); + +/** The parts of a record path as a test path reads it, or undefined where the accessors cannot give it. */ +const pathParts = (path: string): { parts: TPathPart[]; selector: boolean } | undefined => { + const parts: TPathPart[] = []; + let selector = false; + let segment = ''; + const flush = () => { + if (segment !== '') parts.push({ kind: 'field', name: segment }); + segment = ''; + }; + for (let i = 0; i < path.length; ++i) { + const ch = path[i]; + if (ch === '\\') { + if (i + 1 >= path.length) return undefined; + segment += path[++i]; + } else if (ch === '.') { + flush(); + } else if (ch === '[') { + flush(); + const end = path.indexOf(']', i + 1); + if (end < 0) return undefined; + const raw = path.slice(i + 1, end); + if (raw === '*') parts.push({ kind: 'list' }); + else if (/^\d+$/.test(raw)) parts.push({ kind: 'index', index: Number(raw) }); + else return undefined; + selector = true; + i = end; + } else if (ch === '{' && path.startsWith('{*}', i)) { + flush(); + parts.push({ kind: 'hash' }); + selector = true; + i += 2; + } else { + segment += ch; + } + } + flush(); + return parts.length && parts[0].kind === 'field' ? { parts, selector } : undefined; +}; + +/** The value accessors apply to: the base, the field reference of the fields read, or the wildcard's item. */ +const accessorBase = (base: TReference | undefined, fields: string[]): TReference => + base ?? + (fields.length ? + { kind: 'field', field: fields.map((name) => name.replace(/\./g, '\\.')).join('.') } + : { kind: 'item' }); + +/** `QorusRecordPath::accessors()`: the reference of the parts from `pos`, read from `base` or the record. */ +const accessors = ( + parts: TPathPart[], + pos: number, + base: TReference | undefined, + fields: string[] +): TReference => { + for (let i = pos; i < parts.length; ++i) { + const part = parts[i]; + switch (part.kind) { + case 'field': + if (base) base = { kind: 'key', base, key: part.name }; + else fields = [...fields, part.name]; + break; + case 'index': + base = { kind: 'index', base: accessorBase(base, fields), index: part.index }; + fields = []; + break; + case 'list': + return { + kind: 'list', + base: accessorBase(base, fields), + body: accessors(parts, i + 1, undefined, []), + }; + case 'hash': + return { + kind: 'hash', + base: accessorBase(base, fields), + body: accessors(parts, i + 1, undefined, ['value']), + }; + } + } + return accessorBase(base, fields); +}; + +/** `DpqlSerializer::escapeFieldPath()`: a quote is `\"`, a backslash before a dot stays, any other is `\\`. */ +const escapeFieldPath = (field: string): string => + field.replace(/"|\\(?!\.)/g, (match) => (match === '"' ? '\\"' : '\\\\')); + +/** `DpqlSerializer::serializeFieldRef()`. */ +const fieldReference = (field: string): string => + BARE_FIELD.test(field) ? `@${field}` : `@"${escapeFieldPath(field)}"`; + +/** The keys of a field path: split at its unescaped dots, the escapes read. */ +const fieldPathKeys = (field: string): string[] => { + const keys: string[] = []; + let key = ''; + for (let i = 0; i < field.length; ++i) { + if (field[i] === '\\' && i + 1 < field.length) key += field[++i]; + else if (field[i] === '.') { + keys.push(key); + key = ''; + } else key += field[i]; + } + keys.push(key); + return keys; +}; + +/** `DpqlSerializer::serializeKeyAccessor()`: `.name` after an accessor, else `{key}` or `{"key"}`. */ +const keyAccessor = (key: string, afterAccessor: boolean): string => + afterAccessor && isBareMember(key) ? + `.${key}` + : `{${isBareKey(key) ? key : JSON.stringify(key)}}`; + +const isAccessor = (reference: TReference) => + reference.kind === 'index' || reference.kind === 'key'; + +/** `DpqlSerializer::serializeWildcardFieldPath()`. */ +const wildcardFieldPath = (field: string, op: 'list' | 'hash'): string | undefined => { + const keys = fieldPathKeys(field); + if (op === 'hash') { + if (keys[0] !== 'value') return undefined; + keys.shift(); + } + if (keys.every(isBareMember)) return keys.length ? `.${keys.join('.')}` : ''; + return op === 'list' && keys.length === 1 ? `{${JSON.stringify(keys[0])}}` : undefined; +}; + +/** `DpqlSerializer::serializeWildcardBody()`: the accessors after `[*]` or `{*}`, or undefined. */ +const wildcardBody = (body: TReference, op: 'list' | 'hash'): string | undefined => { + switch (body.kind) { + case 'field': + return wildcardFieldPath(body.field, op); + case 'item': + return op === 'list' ? '' : undefined; + case 'index': { + const base = wildcardBody(body.base, op); + return base === undefined ? undefined : `${base}[${body.index}]`; + } + case 'key': { + const base = wildcardBody(body.base, op); + return base === undefined ? undefined : base + keyAccessor(body.key, isAccessor(body.base)); + } + case 'list': + case 'hash': { + const base = wildcardBody(body.base, op); + const inner = wildcardBody(body.body, body.kind); + return base === undefined || inner === undefined ? + undefined + : `${base}${body.kind === 'list' ? '[*]' : '{*}'}${inner}`; + } + } +}; + +/** A reference as `dpql/serialize` writes it, or undefined where the accessors cannot write it. */ +const serialize = (reference: TReference): string | undefined => { + switch (reference.kind) { + case 'field': + return fieldReference(reference.field); + case 'item': + return undefined; + case 'index': { + const base = serialize(reference.base); + return base === undefined ? undefined : `${base}[${reference.index}]`; + } + case 'key': { + const base = serialize(reference.base); + return base === undefined ? undefined : ( + base + keyAccessor(reference.key, isAccessor(reference.base)) + ); + } + case 'list': + case 'hash': { + const base = serialize(reference.base); + const body = wildcardBody(reference.body, reference.kind); + return base === undefined || body === undefined ? + undefined + : `${base}${reference.kind === 'list' ? '[*]' : '{*}'}${body}`; + } + } +}; + +/** + * A `$record:{}` template as `dpql/serialize` writes the reference it is - `@create.status`, + * `@"create.order-id"`, `@"Unit\.Price"`, `@orders[*].id`, `@prices{*}.amount`, `@items[0].sku` - or undefined for + * any other value, and for a path the accessors cannot write (it stays the template). + */ +export const recordReferenceDpql = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const inner = /^\$record:\{(.*)\}$/s.exec(value)?.[1]; + if (inner === undefined) return undefined; + const path = recordTemplatePath(inner); + if (!/[[{\\]/.test(path)) { + return path === '' ? undefined : fieldReference(path); + } + const read = pathParts(path); + if (!read) return undefined; + if (!read.selector) { + return serialize( + accessorBase( + undefined, + read.parts.map((part) => (part as { name: string }).name) + ) + ); + } + return serialize(accessors(read.parts, 0, undefined, [])); +}; + +/** A key of a test path: a dot, a bracket, a brace or a backslash in it is escaped. */ +const testPathKey = (key: string): string => key.replace(/[.[{\\]/g, (ch) => `\\${ch}`); + +/** The accessors a field reference may be followed by, as `dpql/parse` reads them. */ +const ACCESSOR = + /^(?:\[\*\]|\[\d+\]|\{\*\}|\.[\p{L}_](?:[\p{L}\p{M}\p{N}_]|-[a-zA-Z])*|\{"(?:\\.|[^"\\])*"\}|\{[A-Za-z_]\w*\})/u; + +/** A field reference, bare or quoted, at the start of a text. */ +const FIELD_REFERENCE = + /^@(?:"((?:\\.|[^"\\])*)"|([\p{L}_][\p{L}\p{M}\p{N}_]*(?:\.[\p{L}\p{M}\p{N}_]+)*))/u; + +/** + * The record field reference at the start of `text` - `@pos`, `@"create.order-id"`, `@orders[*].id` - read as the + * template the server gives it (`$record:{create.order-id}`, `$record:{orders[*].id}`) and the length it takes, or + * undefined where there is none. + */ +export const readRecordReference = ( + text: string +): { template: string; length: number } | undefined => { + const match = FIELD_REFERENCE.exec(text); + if (!match) return undefined; + let path: string; + if (match[1] !== undefined) { + // a quoted name: dots separate its parts, `\.` is a dot of a key, `\"` and `\\` are a quote and a backslash + const keys: string[] = []; + let key = ''; + const quoted = match[1]; + for (let i = 0; i < quoted.length; ++i) { + const ch = quoted[i]; + if (ch === '\\' && i + 1 < quoted.length) { + key += quoted[++i]; + } else if (ch === '.') { + keys.push(key); + key = ''; + } else key += ch; + } + keys.push(key); + path = keys.map(testPathKey).join('.'); + } else { + path = match[2]; + } + let length = match[0].length; + for (let rest = text.slice(length), accessor = ACCESSOR.exec(rest); accessor;) { + const written = accessor[0]; + if (written.startsWith('[') || written === '{*}') { + path += written; + } else if (written.startsWith('.')) { + path += written; + } else { + const key = + written.startsWith('{"') ? + (JSON.parse(written.slice(1, -1)) as string) + : written.slice(1, -1); + path += `.${testPathKey(key)}`; + } + length += written.length; + rest = text.slice(length); + accessor = ACCESSOR.exec(rest); + } + return { template: `$record:{${recordTemplateText(path)}}`, length }; +}; From 1fe0d104f623720b72eba547b820ac67ccca5c9f Mon Sep 17 00:00:00 2001 From: David Nichols Date: Mon, 19 Oct 2026 12:41:45 +0200 Subject: [PATCH 178/262] test(stories): the stories' server writes a record template as the Qorus IDE does: every backslash and brace escaped but {*} (qorus#646) 44c9a2bf read a field reference back to a template that escaped only a backslash before a brace or a backslash, so the key Unit.Price under create was $record:{create.Unit\.Price}. Qorus decodes that to the same path (QorusRecordPath::templatePath), but the Qorus IDE writes it $record:{create.Unit\\.Price} (recordTemplateOf, b71f6b586): the path is escaped first, then its backslashes and braces again for the template, the hash wildcard {*} left as it is. A story whose stored value came from the IDE would not round-trip through the stand-in. The stand-in now writes the IDE's form, and still reads both. --- __tests__/dpqlMockRecordPath.test.ts | 11 +++++++---- src/components/form/expressions/dpqlMockRecordPath.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/__tests__/dpqlMockRecordPath.test.ts b/__tests__/dpqlMockRecordPath.test.ts index e4788df1..f55de85c 100644 --- a/__tests__/dpqlMockRecordPath.test.ts +++ b/__tests__/dpqlMockRecordPath.test.ts @@ -23,8 +23,9 @@ const WRITTEN: Array<[string, string]> = [ // a part that is not a name: the path is one quoted name, its dots still separating its parts ['$record:{create.order-id}', '@"create.order-id"'], ['$record:{a b}', '@"a b"'], - // a dot that is part of a key (qore#5480) - ['$record:{Unit\\.Price}', '@"Unit\\.Price"'], + // a dot that is part of a key (qore#5480), in a template as the Qorus IDE writes it: the path create.Unit\.Price + // with its backslash escaped for the template + ['$record:{create.Unit\\\\.Price}', '@"create.Unit\\.Price"'], // the accessors, as the serializer writes them ['$record:{orders[*].id}', '@orders[*].id'], ['$record:{create.items[*].id}', '@create.items[*].id'], @@ -43,8 +44,10 @@ describe('a record field written by the stories’ server', () => { expect(recordReferenceDpql(template)).toBe(dpql); }); - it('reads the template escapes of the braces first', () => { + it('reads the template escapes first', () => { expect(recordReferenceDpql('$record:{prices\\{*\\}.amount}')).toBe('@prices{*}.amount'); + // a backslash that escapes nothing of the template's is the path's own, as Qorus reads it + expect(recordReferenceDpql('$record:{Unit\\.Price}')).toBe('@"Unit\\.Price"'); }); it('leaves what is not a record field, and a path the accessors cannot give', () => { @@ -75,7 +78,7 @@ describe('a record field read by the stories’ server', () => { expect(readRecordReference('@headers{"content-type"}')?.template).toBe( '$record:{headers.content-type}' ); - expect(readRecordReference('@headers{"a.b"}')?.template).toBe('$record:{headers.a\\.b}'); + expect(readRecordReference('@headers{"a.b"}')?.template).toBe('$record:{headers.a\\\\.b}'); }); it('is nothing where the text does not start with a field', () => { diff --git a/src/components/form/expressions/dpqlMockRecordPath.ts b/src/components/form/expressions/dpqlMockRecordPath.ts index bda07b49..f3632f91 100644 --- a/src/components/form/expressions/dpqlMockRecordPath.ts +++ b/src/components/form/expressions/dpqlMockRecordPath.ts @@ -47,8 +47,13 @@ type TReference = /** The text in the braces of a template with the template's escapes (`\{`, `\}`, `\\`) decoded. */ export const recordTemplatePath = (text: string): string => text.replace(/\\([{}\\])/g, '$1'); -/** The text in the braces of a template that decodes to `path`: a backslash before `{`, `}` or `\` is doubled. */ -const recordTemplateText = (path: string): string => path.replace(/\\(?=[{}\\])/g, '\\\\'); +/** + * The text in the braces of a template that decodes to `path`, as the Qorus IDE writes it (`recordTemplateOf`): + * every backslash and every brace is escaped, except the hash wildcard `{*}`. `Unit.Price` under `create` (the path + * `create.Unit\.Price`) is `$record:{create.Unit\\.Price}`. + */ +const recordTemplateText = (path: string): string => + path.replace(/\{\*\}|[\\{}]/g, (found) => (found === '{*}' ? found : `\\${found}`)); /** The parts of a record path as a test path reads it, or undefined where the accessors cannot give it. */ const pathParts = (path: string): { parts: TPathPart[]; selector: boolean } | undefined => { From d570b90f649aba47ed7e875ae09ba90050233fdf Mon Sep 17 00:00:00 2001 From: David Nichols Date: Mon, 19 Oct 2026 12:53:22 +0200 Subject: [PATCH 179/262] fix(dpql-editor): a record field is one chip: the template chip, labeled record: , wherever it is drawn (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit David's decision: a record field is the same chip wherever it appears, in style and in label. The Expression tab drew a field written in DPQL (@orders[*].id) as a light blue chip with black text, labeled orders[*].id (FIELD_REF_COLOR); the Visual tab drew the same field, held as $record:{orders[*].id}, as a template chip: the info intent, the template icon, "record: orders[*].id". Round 8 makes every record field on the Expression tab a DPQL field, so the two were side by side everywhere. makeDpqlTagRenderer now draws a field reference as the chip of the template it stands for (recordTemplateOfReference: @orders[*].id is $record:{orders[*].id}, as Qorus reads it): the template's color (its catalogue entry's intent, else info), icon and label - the catalogue's name for it where there is one (pos), else record: . The record's own description of the field is its hover where the catalogue gives none; a field with neither has no hover of its own, so the server's is the one shown and its text is not repeated as a tooltip. A template chip is as it was. A field described by the record (display_name) is labeled as the template now, its name and description in its hover. The reader of a field reference moves from the stories' stand-in to helpers/recordFieldReference.ts, so the stand-in and the chips read a reference alike. Stories, failing before: DpqlEditor · A Record Field Is One Chip (@orders[*].id and $record:{orders[*].id}, the same label and look) and FormEngine · A Record Field Is The Same Chip On Every Tab (Computed's pos on its Expression and Visual tabs). The labels the DpqlEditor stories expect follow (With Accessor Chains, With Quoted Field References, Quoted Field Typed Out Is A Chip, Accessor Chain Typed Out Is A Chip, the hover stories); With Quoted Field References takes @"create.Unit\.Price" (a key with a dot, qore#5480) where it had @"create.items[*].id", which qore#5480 reads as the key items[*]. Unit: __tests__/oneChipForARecordField.test.ts; quotedReferences follows. --- __tests__/dpqlEditor/quotedReferences.test.ts | 16 ++-- __tests__/oneChipForARecordField.test.ts | 63 ++++++++++++++ .../dpqlEditor/DpqlEditor.stories.tsx | 69 +++++++++++---- src/components/dpqlEditor/dpqlTags.ts | 73 ++++++++++------ .../form/engine/FormEngine.stories.tsx | 47 ++++++++++ .../form/expressions/dpqlMockRecordPath.ts | 73 +--------------- src/helpers/recordFieldReference.ts | 86 +++++++++++++++++++ 7 files changed, 307 insertions(+), 120 deletions(-) create mode 100644 __tests__/oneChipForARecordField.test.ts create mode 100644 src/helpers/recordFieldReference.ts diff --git a/__tests__/dpqlEditor/quotedReferences.test.ts b/__tests__/dpqlEditor/quotedReferences.test.ts index c400dc02..0ff0a25d 100644 --- a/__tests__/dpqlEditor/quotedReferences.test.ts +++ b/__tests__/dpqlEditor/quotedReferences.test.ts @@ -95,7 +95,7 @@ describe('a quoted field is drawn as its chip', () => { expect(getTagLabel('@name')).toBe('name'); }); - test('the chip says the name, and the field it names describes it', () => { + test('the chip is the record template it names, and the field it names describes it', () => { const renderTag = makeDpqlTagRenderer({ 'create.order-id': { display_name: 'Order ID', type: { name: 'string' } }, }); @@ -105,15 +105,17 @@ describe('a quoted field is drawn as its chip', () => { children: [{ text: '' }], }); + // one chip for one record field (qorus#646): the template chip, `record: `, with the record's description expect(renderTag(tag('@"create.order-id"'))).toMatchObject({ - label: 'Order ID', - icon: 'Database2Line', + label: 'record: create.order-id', + icon: 'ExchangeDollarLine', + intent: 'info', }); expect(renderTag(tag('@"create.order-id"')).tooltip).toBeTruthy(); - // a field the record does not describe is drawn with its name, unescaped, not its quoted text - expect(renderTag(tag('@"create.items[*].id"')).label).toBe('create.items[*].id'); - expect(renderTag(tag('@"say \\"hi\\""')).label).toBe('say "hi"'); - expect(renderTag(tag('@name')).label).toBe('name'); + // its name unescaped into the path, not its quoted text; a dot of a key stays a dot of a key (qore#5480) + expect(renderTag(tag('@"say \\"hi\\""')).label).toBe('record: say "hi"'); + expect(renderTag(tag('@"create.Unit\\.Price"')).label).toBe('record: create.Unit\\\\.Price'); + expect(renderTag(tag('@name')).label).toBe('record: name'); }); test('typed out, it is drawn as its chip once the character after it ends it', () => { diff --git a/__tests__/oneChipForARecordField.test.ts b/__tests__/oneChipForARecordField.test.ts new file mode 100644 index 00000000..00452158 --- /dev/null +++ b/__tests__/oneChipForARecordField.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { getTagLabel } from '../src/components/dpqlEditor/dpqlHelpers'; +import { makeDpqlTagRenderer } from '../src/components/dpqlEditor/dpqlTags'; +import { recordTemplateOfReference } from '../src/helpers/recordFieldReference'; + +/** + * One chip for one record field (qorus#646, David). A field written in DPQL - `@orders[*].id`, as the server writes + * a record field on the Expression tab - and the template a value holds - `$record:{orders[*].id}`, as the Visual + * tab draws it - are the same field, and are drawn as the same chip: the template chip's color, icon and label + * (`record: orders[*].id`, or the catalogue's name for it). The field was a light blue chip with black text, + * labeled with its path alone. The stories (DpqlEditor · A Record Field Is One Chip, FormEngine · A Record Field Is + * The Same Chip On Every Tab) compare the two as drawn; these check the props the editor draws them with. + */ +const chipOf = (value: string, options?: Parameters[1]) => + makeDpqlTagRenderer({}, options)({ type: 'tag', value, children: [{ text: '' }] } as never); + +describe('a record field written in DPQL', () => { + it('is the template it stands for', () => { + expect(recordTemplateOfReference('@pos')).toBe('$record:{pos}'); + expect(recordTemplateOfReference('@"create.order-id"')).toBe('$record:{create.order-id}'); + expect(recordTemplateOfReference('@orders[*].id')).toBe('$record:{orders[*].id}'); + // only a reference that is the whole text + expect(recordTemplateOfReference('@pos + 1')).toBeUndefined(); + expect(recordTemplateOfReference('pos')).toBeUndefined(); + }); + + it('is drawn as the chip of that template', () => { + const field = chipOf('@orders[*].id'); + const template = chipOf('$record:{orders[*].id}'); + + expect(field).toMatchObject({ + label: 'record: orders[*].id', + icon: 'ExchangeDollarLine', + intent: 'info', + }); + // the same color and icon; the template's chip is labeled by its element, with the label the field's says + const { tooltip: _fieldTooltip, label: fieldLabel, ...fieldLook } = field; + const { tooltip: _templateTooltip, ...templateLook } = template; + expect(fieldLook).toEqual(templateLook); + expect(fieldLabel).toBe(getTagLabel('$record:{orders[*].id}')); + }); + + it('takes the name the catalogue gives the template', () => { + const templates = { + items: [ + { + label: 'Fields of the row', + items: [{ label: 'pos', value: '$record:{pos}', badge: 'int' }], + }, + ], + }; + + expect(chipOf('@pos', { templates } as never).label).toBe('pos'); + expect(chipOf('$record:{pos}', { templates } as never).label).toBe('pos'); + }); + + it('has no hover of its own unless the record describes it, so the server’s hover is the one shown', () => { + const field = chipOf('@orders[*].id'); + + expect('tooltip' in field).toBe(true); + expect(field.tooltip).toBeUndefined(); + }); +}); diff --git a/src/components/dpqlEditor/DpqlEditor.stories.tsx b/src/components/dpqlEditor/DpqlEditor.stories.tsx index 07ce90b5..cfb725be 100644 --- a/src/components/dpqlEditor/DpqlEditor.stories.tsx +++ b/src/components/dpqlEditor/DpqlEditor.stories.tsx @@ -504,14 +504,14 @@ const chipLabels = (editable: HTMLElement) => */ export const WithQuotedFieldReferences: Story = { args: { - value: '@"create.items[*].id" == $record:{create.a\\{b\\}} && @"create.order-id" != "@name"', + value: '@"create.Unit\\.Price" == $record:{create.a\\{b\\}} && @"create.order-id" != "@name"', onChange: fn(), }, parameters: { docs: { description: { story: - 'Renders the DpqlEditor with the quoted fields the server writes for record fields that are not bare names, @"create.items[*].id" and @"create.order-id", a $record template whose value holds escaped braces, and the string "@name". Each field and the template is one chip, labeled with the name it stands for; the string stays text; the text is written back as it was.', + 'Renders the DpqlEditor with the quoted fields the server writes for record fields that are not bare names - @"create.Unit\\.Price", where the key Unit.Price holds a dot (qore#5480), and @"create.order-id" - a $record template whose value holds escaped braces, and the string "@name". Each field and the template is one chip, labeled as the record template it is (record: create.order-id); the string stays text; the text is written back as it was.', }, }, }, @@ -520,9 +520,9 @@ export const WithQuotedFieldReferences: Story = { await waitFor(() => expect(chipLabels(editable)).toEqual([ - 'create.items[*].id', + 'record: create.Unit\\\\.Price', 'record: create.a\\{b\\}', - 'create.order-id', + 'record: create.order-id', ]) ); await expect(editable.textContent?.replace(/\uFEFF/g, '')).toContain('!= "@name"'); @@ -537,7 +537,7 @@ export const QuotedFieldTypedOutIsAChip: Story = { docs: { description: { story: - 'Renders the empty DpqlEditor and types @"create.order-id" == 1 without picking from the list. When the space after the closing quote is typed, the quoted field is drawn as its chip, labeled create.order-id, and the rest is typed after it.', + 'Renders the empty DpqlEditor and types @"create.order-id" == 1 without picking from the list. When the space after the closing quote is typed, the quoted field is drawn as its chip, labeled record: create.order-id, and the rest is typed after it.', }, }, }, @@ -547,7 +547,7 @@ export const QuotedFieldTypedOutIsAChip: Story = { await userEvent.click(editable); await userEvent.type(editable, '@"create.order-id" == 1'); - await waitFor(() => expect(chipLabels(editable)).toEqual(['create.order-id'])); + await waitFor(() => expect(chipLabels(editable)).toEqual(['record: create.order-id'])); await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('@"create.order-id" == 1')); await expect(editable.textContent?.replace(/\uFEFF/g, '')).toMatch(/create\.order-id\s*== 1$/); }, @@ -568,7 +568,7 @@ export const WithAccessorChains: Story = { docs: { description: { story: - 'Renders the DpqlEditor with fields that carry accessors, as the server writes them: @create.items[*].id, @prices{*}, @items[0].id and @headers{"content-type"}. Each is one chip labeled with its whole path - create.items[*].id, prices{*}, items[0].id, headers{"content-type"} - not a chip for the field and plain text for the accessors; the text is written back as it was.', + 'Renders the DpqlEditor with fields that carry accessors, as the server writes them: @create.items[*].id, @prices{*}, @items[0].id and @headers{"content-type"}. Each is one chip labeled with its whole path as the record template it is - record: create.items[*].id, record: prices{*}, record: items[0].id, record: headers.content-type - not a chip for the field and plain text for the accessors; the text is written back as it was.', }, }, }, @@ -577,16 +577,51 @@ export const WithAccessorChains: Story = { await waitFor(() => expect(chipLabels(editable)).toEqual([ - 'create.items[*].id', - 'prices{*}', - 'items[0].id', - 'headers{"content-type"}', + 'record: create.items[*].id', + 'record: prices{*}', + 'record: items[0].id', + 'record: headers.content-type', ]) ); await expect(args.onChange).not.toHaveBeenCalled(); }, }; +/** A chip's look: its colors and its icon. */ +const chipLook = (chip: HTMLElement) => { + const style = getComputedStyle(chip); + return { + background: style.backgroundColor, + color: getComputedStyle(chip.querySelector('.reqore-tag-content') ?? chip).color, + icon: + chip.querySelector('.reqore-icon')?.getAttribute('data-icon') ?? + chip.querySelector('svg')?.innerHTML, + }; +}; + +export const ARecordFieldIsOneChip: Story = { + args: { value: '@orders[*].id == $record:{orders[*].id}', onChange: fn() }, + parameters: { + docs: { + description: { + story: + 'Renders the DpqlEditor with one record field written both ways - @orders[*].id, as the server writes DPQL, and $record:{orders[*].id}, as a value holds it. They are the same field and the same chip: both labeled record: orders[*].id, in the color and with the icon of every template chip (qorus#646, David: one chip for one record field). Before, the field was a light blue chip with black text, labeled orders[*].id.', + }, + }, + }, + play: async ({ canvasElement, args }) => { + const editable = within(canvasElement).getByRole('textbox'); + + await waitFor(() => + expect(chipLabels(editable)).toEqual(['record: orders[*].id', 'record: orders[*].id']) + ); + const [field, template] = Array.from(editable.querySelectorAll('.reqore-tag')); + await expect(chipLook(field)).toEqual(chipLook(template)); + // drawn, not rewritten + await expect(args.onChange).not.toHaveBeenCalled(); + }, +}; + /** The hovers the editor shows, as their text. */ const shownHovers = () => Array.from(document.querySelectorAll('.reqore-popover-content')) @@ -649,7 +684,7 @@ export const HoverOverAChipSaysWhatItsPathIs: Story = { }, play: async ({ canvasElement }) => { const editable = await readyEditor(canvasElement); - const chip = await chipFor(editable, 'create.items[*].id'); + const chip = await chipFor(editable, 'record: create.items[*].id'); try { await restMouseOn(chip, middleOfElement(chip)); await waitFor( @@ -681,7 +716,7 @@ export const DetailsOnAChipOnATouchScreen: Story = { play: async ({ canvasElement }) => { await withTouchScreen(async (touch) => { const editable = await readyEditor(canvasElement); - const chip = await chipFor(editable, 'create.items[*].id'); + const chip = await chipFor(editable, 'record: create.items[*].id'); await touch.tap(chip); const details = await waitFor( () => { @@ -707,13 +742,13 @@ export const AChipWithItsOwnDescriptionShowsOneHover: Story = { docs: { description: { story: - 'Renders @name == "Alice" with the record described, and rests the mouse on the chip Name: the chip shows its own description - "The customer\'s full name, as they gave it" - and the server\'s hover is not drawn over it as well. One hover over a chip, not two.', + 'Renders @name == "Alice" with the record described, and rests the mouse on the chip record: name: the chip shows its own description - "The customer\'s full name, as they gave it" - and the server\'s hover is not drawn over it as well. One hover over a chip, not two.', }, }, }, play: async ({ canvasElement }) => { const editable = await readyEditor(canvasElement); - const chip = await chipFor(editable, 'Name'); + const chip = await chipFor(editable, 'record: name'); try { await restMouseOn(chip, middleOfElement(chip)); await waitFor( @@ -733,7 +768,7 @@ export const AccessorChainTypedOutIsAChip: Story = { docs: { description: { story: - 'Renders the empty DpqlEditor and types @orders[*].id == 1 without picking from the list. When the space after it is typed, the field and its accessors are drawn as one chip, orders[*].id, and the rest is typed after it.', + 'Renders the empty DpqlEditor and types @orders[*].id == 1 without picking from the list. When the space after it is typed, the field and its accessors are drawn as one chip, record: orders[*].id, and the rest is typed after it.', }, }, }, @@ -744,7 +779,7 @@ export const AccessorChainTypedOutIsAChip: Story = { // `[` starts a key descriptor for userEvent; `[[` types one await userEvent.type(editable, '@orders[[*].id == 1'); - await waitFor(() => expect(chipLabels(editable)).toEqual(['orders[*].id'])); + await waitFor(() => expect(chipLabels(editable)).toEqual(['record: orders[*].id'])); await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('@orders[*].id == 1')); }, }; diff --git a/src/components/dpqlEditor/dpqlTags.ts b/src/components/dpqlEditor/dpqlTags.ts index 634c1181..dea74a03 100644 --- a/src/components/dpqlEditor/dpqlTags.ts +++ b/src/components/dpqlEditor/dpqlTags.ts @@ -12,6 +12,8 @@ import { templateTooltip, TTemplateMeta, } from '../../helpers/templates'; +import { recordTemplateOfReference } from '../../helpers/recordFieldReference'; +import { templateChipLabel } from '../../helpers/templateText'; import { ISlateElement } from '../smartEditor/types'; import { dpqlFieldName } from './dpqlHelpers'; import { IDpqlFieldMeta } from './useDpqlSession'; @@ -86,48 +88,67 @@ export function makeDpqlTagRenderer( fieldMeta: Record, options: IDpqlTagRendererOptions = {} ) { + /** + * A template's chip, as every view draws it: its catalogue name and description, else `: `. A field + * gives its own hover (`field`): the record's description of it, or none, so that the server's hover is the one + * shown and the chip's text is not repeated as its tooltip. + */ + const templateChip = ( + template: string, + field?: { tooltip: IReqoreTagProps['tooltip'] } + ): IReqoreTagProps => { + const named = options.templates && describeTemplateReference(options.templates, template); + return { + ...REFERENCE_CHIP_PROPS, + icon: 'ExchangeDollarLine', + /* the catalogue's name for it; else a template keeps the label its element was given (`: `), and a + field, whose element is labeled with its name, says the template's */ + ...(named?.item ? { label: named.label } + : field ? { label: templateChipLabel(options.templates, template) } + : {}), + ...(named?.item ? { tooltip: templateTooltip(options.templates, template, options.descriptions ?? {}) } + : field ? { tooltip: field.tooltip } + : {}), + ...(options.templateTagsUseIntent === false ? + { color: getTemplatePrefixColor(template) as `#${string}` } + : getTemplateTagStyle(named?.item?.metadata as TTemplateMeta | undefined)), + }; + }; + return (tag: ISlateElement): IReqoreTagProps => { const tagValue = tag.value?.toString(); if (!tagValue) return {}; - // a field, `@name` or quoted `@"order-id"`: drawn with its name, and described by the record's field of it + /* A field, `@name`, quoted `@"order-id"` or with its accessors `@orders[*].id`, is a field of the record: the + same record field a value holds as `$record:{orders[*].id}`, and drawn as that chip - its color, its icon + and its label, `record: orders[*].id` (qorus#646, David: one chip for one record field). The record's own + description of the field is its hover where the catalogue does not describe it. */ const fieldName = dpqlFieldName(tagValue); if (fieldName !== undefined) { const meta = fieldMeta[fieldName]; // the field's name and type head the card, and its description is drawn under them - const tooltip = meta - ? descriptionTooltip( + const tooltip = + meta ? + descriptionTooltip( meta.short_desc, options.descriptions ?? {}, `${meta.display_name || fieldName}${meta.type?.name ? ` (${meta.type.name})` : ''}` ) : undefined; - - return { - // the same chip every view draws a reference as: the size of the text it sits in - ...REFERENCE_CHIP_PROPS, - icon: 'Database2Line', - color: FIELD_REF_COLOR.fg as `#${string}`, - tooltip, - label: meta?.display_name || fieldName, - }; + const template = recordTemplateOfReference(tagValue); + return template ? + templateChip(template, { tooltip }) + : { + ...REFERENCE_CHIP_PROPS, + icon: 'ExchangeDollarLine', + label: fieldName, + tooltip, + ...getTemplateTagStyle(undefined), + }; } if (tagValue.startsWith('$')) { - const named = options.templates && describeTemplateReference(options.templates, tagValue); - return { - ...REFERENCE_CHIP_PROPS, - icon: 'ExchangeDollarLine', - ...(named?.item ? - { - label: named.label, - tooltip: templateTooltip(options.templates, tagValue, options.descriptions ?? {}), - } - : {}), - ...(options.templateTagsUseIntent === false ? - { color: getTemplatePrefixColor(tagValue) as `#${string}` } - : getTemplateTagStyle(named?.item?.metadata as TTemplateMeta | undefined)), - }; + return templateChip(tagValue); } return {}; diff --git a/src/components/form/engine/FormEngine.stories.tsx b/src/components/form/engine/FormEngine.stories.tsx index f5eda0c0..14274c35 100644 --- a/src/components/form/engine/FormEngine.stories.tsx +++ b/src/components/form/engine/FormEngine.stories.tsx @@ -11263,6 +11263,53 @@ export const ExpressionWrittenOnExpressionShowsWhenTheTabIsBack: Story = { }, }; +/** A reference chip's look: its label, colors and icon. */ +const referenceChipLook = (chip: HTMLElement) => ({ + label: (chip.textContent ?? '').replace(/\uFEFF/g, '').trim(), + background: getComputedStyle(chip).backgroundColor, + color: getComputedStyle(chip.querySelector('.reqore-tag-content') ?? chip).color, + icon: chip.querySelector('svg')?.innerHTML, +}); + +/** The reference chips in `root` that say `label`. */ +const chipsSaying = (root: Element, label: string) => + Array.from(root.querySelectorAll('.reqore-tag')).filter( + (chip) => (chip.textContent ?? '').replace(/\uFEFF/g, '').trim() === label + ); + +export const ARecordFieldIsTheSameChipOnEveryTab: Story = { + parameters: { + docs: { + description: { + story: + 'Renders the form and opens Computed (the expression pos + 1) on its Expression tab, where the server writes the field as @pos, then on its Visual tab, where it is the operand $record:{pos}. The field pos is the same chip on both: the same label, color and icon (qorus#646, David: one chip for one record field). Before, the Expression tab drew it light blue with black text.', + }, + }, + }, + render: () => , + play: async () => { + const row = await editOption('computed'); + await waitFor(() => expect(optionActiveTab(row)).toBe('expression')); + // on the Expression tab, the field of `@pos + 1` + const onExpression = await waitFor(() => { + const chip = optionRow('computed').querySelector( + '.expression-field [data-slate-editor] .reqore-tag' + ); + expect(chip, 'the field on the Expression tab').toBeTruthy(); + return referenceChipLook(chip!); + }); + await expect(onExpression.label).toBe('pos'); + // on the Visual tab, the operand $record:{pos}: the same chip + await chooseTab(optionRow('computed'), 'visual'); + const onVisual = await waitFor(() => { + const [chip] = chipsSaying(optionRow('computed'), onExpression.label); + expect(chip, 'the operand on the Visual tab').toBeTruthy(); + return referenceChipLook(chip); + }); + await expect(onExpression).toEqual(onVisual); + }, +}; + /** Computed as the form was given it: the expression pos + 1. */ const COMPUTED_EXPRESSION = TEMPLATE_OR_EXPRESSION_VALUE.computed; diff --git a/src/components/form/expressions/dpqlMockRecordPath.ts b/src/components/form/expressions/dpqlMockRecordPath.ts index f3632f91..f15ed281 100644 --- a/src/components/form/expressions/dpqlMockRecordPath.ts +++ b/src/components/form/expressions/dpqlMockRecordPath.ts @@ -14,6 +14,9 @@ // // What the serializer cannot write as accessors (a wildcard body it writes with `map`) is left as the template. // Pure: no React, no transport. +import { readRecordReference } from '../../../helpers/recordFieldReference'; + +export { readRecordReference }; /** Qore `DpqlTokenizer::isBareFieldName()`: a name, then `.` and more parts of letters, digits and `_`. */ const BARE_FIELD = /^[\p{L}_][\p{L}\p{M}\p{N}_]*(?:\.[\p{L}\p{M}\p{N}_]+)*$/u; @@ -47,14 +50,6 @@ type TReference = /** The text in the braces of a template with the template's escapes (`\{`, `\}`, `\\`) decoded. */ export const recordTemplatePath = (text: string): string => text.replace(/\\([{}\\])/g, '$1'); -/** - * The text in the braces of a template that decodes to `path`, as the Qorus IDE writes it (`recordTemplateOf`): - * every backslash and every brace is escaped, except the hash wildcard `{*}`. `Unit.Price` under `create` (the path - * `create.Unit\.Price`) is `$record:{create.Unit\\.Price}`. - */ -const recordTemplateText = (path: string): string => - path.replace(/\{\*\}|[\\{}]/g, (found) => (found === '{*}' ? found : `\\${found}`)); - /** The parts of a record path as a test path reads it, or undefined where the accessors cannot give it. */ const pathParts = (path: string): { parts: TPathPart[]; selector: boolean } | undefined => { const parts: TPathPart[] = []; @@ -258,65 +253,3 @@ export const recordReferenceDpql = (value: unknown): string | undefined => { } return serialize(accessors(read.parts, 0, undefined, [])); }; - -/** A key of a test path: a dot, a bracket, a brace or a backslash in it is escaped. */ -const testPathKey = (key: string): string => key.replace(/[.[{\\]/g, (ch) => `\\${ch}`); - -/** The accessors a field reference may be followed by, as `dpql/parse` reads them. */ -const ACCESSOR = - /^(?:\[\*\]|\[\d+\]|\{\*\}|\.[\p{L}_](?:[\p{L}\p{M}\p{N}_]|-[a-zA-Z])*|\{"(?:\\.|[^"\\])*"\}|\{[A-Za-z_]\w*\})/u; - -/** A field reference, bare or quoted, at the start of a text. */ -const FIELD_REFERENCE = - /^@(?:"((?:\\.|[^"\\])*)"|([\p{L}_][\p{L}\p{M}\p{N}_]*(?:\.[\p{L}\p{M}\p{N}_]+)*))/u; - -/** - * The record field reference at the start of `text` - `@pos`, `@"create.order-id"`, `@orders[*].id` - read as the - * template the server gives it (`$record:{create.order-id}`, `$record:{orders[*].id}`) and the length it takes, or - * undefined where there is none. - */ -export const readRecordReference = ( - text: string -): { template: string; length: number } | undefined => { - const match = FIELD_REFERENCE.exec(text); - if (!match) return undefined; - let path: string; - if (match[1] !== undefined) { - // a quoted name: dots separate its parts, `\.` is a dot of a key, `\"` and `\\` are a quote and a backslash - const keys: string[] = []; - let key = ''; - const quoted = match[1]; - for (let i = 0; i < quoted.length; ++i) { - const ch = quoted[i]; - if (ch === '\\' && i + 1 < quoted.length) { - key += quoted[++i]; - } else if (ch === '.') { - keys.push(key); - key = ''; - } else key += ch; - } - keys.push(key); - path = keys.map(testPathKey).join('.'); - } else { - path = match[2]; - } - let length = match[0].length; - for (let rest = text.slice(length), accessor = ACCESSOR.exec(rest); accessor;) { - const written = accessor[0]; - if (written.startsWith('[') || written === '{*}') { - path += written; - } else if (written.startsWith('.')) { - path += written; - } else { - const key = - written.startsWith('{"') ? - (JSON.parse(written.slice(1, -1)) as string) - : written.slice(1, -1); - path += `.${testPathKey(key)}`; - } - length += written.length; - rest = text.slice(length); - accessor = ACCESSOR.exec(rest); - } - return { template: `$record:{${recordTemplateText(path)}}`, length }; -}; diff --git a/src/helpers/recordFieldReference.ts b/src/helpers/recordFieldReference.ts new file mode 100644 index 00000000..63ca727b --- /dev/null +++ b/src/helpers/recordFieldReference.ts @@ -0,0 +1,86 @@ +// Copyright 2026 Qore Technologies, s.r.o. +// A record field reference in DPQL text - `@pos`, `@"create.order-id"`, `@orders[*].id` - read as the `$record:{…}` +// template it stands for (qorus#646). Qorus reads `$record:{}` as a test path (`QorusRecordPath`), and its DPQL +// serializer writes the same path after `@` (qore#5480: dots separate the parts of a name, quoted or bare, and a dot +// that is part of a key is `\.`). A field written in DPQL and the template a value holds are one record field, so they +// are drawn as one chip; and the stories' stand-in for the server reads the reference back to the template with this. +// Pure: no React, no transport. + +/** + * The text in the braces of a template that decodes to `path`, as the Qorus IDE writes it (`recordTemplateOf`): + * every backslash and every brace is escaped, except the hash wildcard `{*}`. `Unit.Price` under `create` (the path + * `create.Unit\.Price`) is `$record:{create.Unit\\.Price}`. + */ +const recordTemplateText = (path: string): string => + path.replace(/\{\*\}|[\\{}]/g, (found) => (found === '{*}' ? found : `\\${found}`)); + +/** A key of a test path: a dot, a bracket, a brace or a backslash in it is escaped. */ +const testPathKey = (key: string): string => key.replace(/[.[{\\]/g, (ch) => `\\${ch}`); + +/** The accessors a field reference may be followed by, as `dpql/parse` reads them. */ +const ACCESSOR = + /^(?:\[\*\]|\[\d+\]|\{\*\}|\.[\p{L}_](?:[\p{L}\p{M}\p{N}_]|-[a-zA-Z])*|\{"(?:\\.|[^"\\])*"\}|\{[A-Za-z_]\w*\})/u; + +/** A field reference, bare or quoted, at the start of a text. */ +const FIELD_REFERENCE = + /^@(?:"((?:\\.|[^"\\])*)"|([\p{L}_][\p{L}\p{M}\p{N}_]*(?:\.[\p{L}\p{M}\p{N}_]+)*))/u; + +/** + * The record field reference at the start of `text` - `@pos`, `@"create.order-id"`, `@orders[*].id` - read as the + * template the server gives it (`$record:{create.order-id}`, `$record:{orders[*].id}`) and the length it takes, or + * undefined where there is none. + */ +export const readRecordReference = ( + text: string +): { template: string; length: number } | undefined => { + const match = FIELD_REFERENCE.exec(text); + if (!match) return undefined; + let path: string; + if (match[1] !== undefined) { + // a quoted name: dots separate its parts, `\.` is a dot of a key, `\"` and `\\` are a quote and a backslash + const keys: string[] = []; + let key = ''; + const quoted = match[1]; + for (let i = 0; i < quoted.length; ++i) { + const ch = quoted[i]; + if (ch === '\\' && i + 1 < quoted.length) { + key += quoted[++i]; + } else if (ch === '.') { + keys.push(key); + key = ''; + } else key += ch; + } + keys.push(key); + path = keys.map(testPathKey).join('.'); + } else { + path = match[2]; + } + let length = match[0].length; + for (let rest = text.slice(length), accessor = ACCESSOR.exec(rest); accessor;) { + const written = accessor[0]; + if (written.startsWith('[') || written === '{*}') { + path += written; + } else if (written.startsWith('.')) { + path += written; + } else { + const key = + written.startsWith('{"') ? + (JSON.parse(written.slice(1, -1)) as string) + : written.slice(1, -1); + path += `.${testPathKey(key)}`; + } + length += written.length; + rest = text.slice(length); + accessor = ACCESSOR.exec(rest); + } + return { template: `$record:{${recordTemplateText(path)}}`, length }; +}; + +/** + * The `$record:{…}` template a field reference stands for, when `reference` is exactly one field reference + * (`@orders[*].id` is `$record:{orders[*].id}`), or undefined. + */ +export const recordTemplateOfReference = (reference: string): string | undefined => { + const read = readRecordReference(reference); + return read && read.length === reference.length ? read.template : undefined; +}; From 9171f129992e09fadbda1bdf570e096b78193849 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Mon, 19 Oct 2026 12:59:17 +0200 Subject: [PATCH 180/262] feat(form-engine): a host's tall editors open in the card; no preview under a host summary; a read-only form opens them to be read (qorus#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the Qorus IDE's structured-data editor (round 1), three changes that go together. R1. A host editor opens as the card. CompactRow opened an editor in the card below its row only when its edit type was in COMPACT_COMPLEX_TYPES, a fixed list that named some host editors (processor-mappings, schema-definition). The IDE's sheet-contract editor opened in the row's value column, about 240px wide. A new FormEngine prop, cardEditorTypes, names the host's editors that open in the card; the existing list keeps working. R2. No preview under a host summary. Where the host draws a row's closed summary (readSummaries), the row drew its own structured preview under it too: "4 columns · 1 rule", then "columns · List · 4 items · Show more", the same value again. Now a row the host summarizes draws none of its previews (structured, code, markdown, long text). For processor-mappings, whose summary the IDE draws, the preview of its mappings goes the same way: the summary says how the mappings stand, and the card shows them. R3. A read-only form opens such a row to be read. A read-only row opened nothing, so with R2 a read-only form showed a sheet contract as one line and nothing more. A row of a host card editor, or one the host summarizes, now opens its card in a read-only form, with the editor read-only (the form's readOnly reaches it, and the IDE's editors disable every control), Close in place of Done and nothing to clear or revert; its ⋯ menu offers View fullscreen (not Edit fullscreen), a view titled Focused View. Such a row is a button named "View