@qoretechnologies/reqraft 0.10.59-pr.130.gff5fd49 → 0.10.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +18 -14
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  5. package/dist/components/form/engine/CompactToolbar.js +1 -1
  6. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  7. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  8. package/dist/components/form/engine/FormEngine.js +24 -29
  9. package/dist/components/form/engine/FormEngine.js.map +1 -1
  10. package/dist/components/form/engine/OptionFieldMessages.d.ts +7 -14
  11. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  12. package/dist/components/form/engine/OptionFieldMessages.js +22 -6
  13. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  14. package/dist/components/form/engine/compactRowContext.d.ts +2 -0
  15. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  16. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  17. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  18. package/dist/components/form/engine/compactRowStyles.js +33 -27
  19. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  20. package/dist/components/form/engine/revealRow.d.ts +53 -0
  21. package/dist/components/form/engine/revealRow.d.ts.map +1 -0
  22. package/dist/components/form/engine/revealRow.js +120 -0
  23. package/dist/components/form/engine/revealRow.js.map +1 -0
  24. package/dist/helpers/allowedValues.d.ts +11 -1
  25. package/dist/helpers/allowedValues.d.ts.map +1 -1
  26. package/dist/helpers/allowedValues.js +25 -2
  27. package/dist/helpers/allowedValues.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/components/form/engine/CompactRow.tsx +21 -19
  30. package/src/components/form/engine/CompactToolbar.tsx +8 -1
  31. package/src/components/form/engine/FormEngine.stories.tsx +499 -9
  32. package/src/components/form/engine/FormEngine.tsx +28 -23
  33. package/src/components/form/engine/OptionFieldMessages.tsx +27 -18
  34. package/src/components/form/engine/compactRowContext.ts +2 -0
  35. package/src/components/form/engine/compactRowStyles.ts +42 -0
  36. package/src/components/form/engine/revealRow.ts +123 -0
  37. package/src/helpers/allowedValues.ts +35 -2
  38. package/src/stories/Tests/utils.ts +36 -0
@@ -23,6 +23,7 @@ import {
23
23
  _testsWaitForText,
24
24
  _testsWaitForTextsCount,
25
25
  _testsWaitForTextToNotExist,
26
+ _testsWithViewport,
26
27
  sleep,
27
28
  } from '../../../stories/Tests/utils';
28
29
  import { mockExpressions } from '../expressions/mockExpressions';
@@ -2934,6 +2935,99 @@ export const CompactWithPanelProps: Story = {
2934
2935
  },
2935
2936
  };
2936
2937
 
2938
+ /**
2939
+ * The outside-dressed panel, its toolbar and its rows all fit the width they
2940
+ * are given: nothing scrolls sideways, the label, the host's action and the
2941
+ * toolbar are on screen, and every row's label and value lie inside the form.
2942
+ */
2943
+ const assertPanelPropsFitNarrowWidth = async (canvasElement: HTMLElement) => {
2944
+ await _testsWaitForText('Connection settings');
2945
+ await _testsWaitForText('order-fulfilment');
2946
+ const form = canvasElement.querySelector<HTMLElement>('.options-readfirst-scroll')!;
2947
+ await waitFor(() => expect(form.querySelector('.readfirst-narrow')).toBeTruthy());
2948
+ const formRect = form.getBoundingClientRect();
2949
+ // No sideways scroll: not the page, not the form.
2950
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
2951
+ document.documentElement.clientWidth
2952
+ );
2953
+ expect(form.scrollWidth).toBeLessThanOrEqual(form.clientWidth + 1);
2954
+ const inside = (element: Element | null, what: string) => {
2955
+ expect(element, what).toBeTruthy();
2956
+ const rect = element!.getBoundingClientRect();
2957
+ expect(rect.width, `${what} is drawn`).toBeGreaterThan(0);
2958
+ expect(rect.left, `${what} starts inside the form`).toBeGreaterThanOrEqual(formRect.left - 1);
2959
+ expect(rect.right, `${what} ends inside the form`).toBeLessThanOrEqual(formRect.right + 1);
2960
+ };
2961
+ // The panel chrome supplied from outside, and the engine's own toolbar.
2962
+ const panelTitle = [...canvasElement.querySelectorAll<HTMLElement>('.reqore-panel-title')].find(
2963
+ (title) => (title.textContent || '').includes('Connection settings')
2964
+ )!;
2965
+ inside(panelTitle, 'the panel header');
2966
+ // The label keeps its width: squeezed to nothing was how it went missing.
2967
+ inside(panelTitle.querySelector('.reqore-panel-title-label-row'), 'the panel label');
2968
+ expect(
2969
+ panelTitle.querySelector('.reqore-panel-title-label-row')!.getBoundingClientRect().width
2970
+ ).toBeGreaterThan(100);
2971
+ inside(
2972
+ [...panelTitle.querySelectorAll('.reqore-button')].find((button) =>
2973
+ (button.textContent || '').includes('Docs')
2974
+ ) ?? null,
2975
+ 'the Docs action'
2976
+ );
2977
+ inside(form.querySelector('.options-readfirst-completion'), 'the completion meter');
2978
+ // Every row's label and value.
2979
+ const rows = [...form.querySelectorAll<HTMLElement>('.readfirst-row')];
2980
+ expect(rows.length).toBeGreaterThan(0);
2981
+ for (const row of rows) {
2982
+ inside(row, `row ${row.dataset.field}`);
2983
+ }
2984
+ };
2985
+
2986
+ export const CompactWithPanelPropsPhone: Story = {
2987
+ ...CompactWithPanelProps,
2988
+ parameters: {
2989
+ qlip: { viewport: { width: 390, height: 844 } },
2990
+ docs: {
2991
+ description: {
2992
+ story:
2993
+ 'Compact With Panel Props on a 390px phone: the dressed panel header, its Docs action, the toolbar and every row fit the screen, the rows stack their values under their names, and nothing scrolls sideways.',
2994
+ },
2995
+ },
2996
+ },
2997
+ play: async ({ canvasElement }) => {
2998
+ // The capture is taken at 390px after the play; the play narrows the
2999
+ // viewport itself so it asserts the layout that is captured.
3000
+ await _testsWithViewport(390, 844, async (narrowed) => {
3001
+ await _testsWaitForText('Connection settings');
3002
+ if (narrowed) {
3003
+ await assertPanelPropsFitNarrowWidth(canvasElement);
3004
+ }
3005
+ });
3006
+ },
3007
+ };
3008
+
3009
+ export const CompactWithPanelPropsNarrow: Story = {
3010
+ ...CompactWithPanelProps,
3011
+ parameters: {
3012
+ docs: {
3013
+ description: {
3014
+ story:
3015
+ 'Compact With Panel Props in a 360px column on a wide screen (a side panel or drawer): the same narrow layout as on a phone, decided by the width the form is given rather than by the screen.',
3016
+ },
3017
+ },
3018
+ },
3019
+ decorators: [
3020
+ (StoryComponent: React.ComponentType) => (
3021
+ <div style={{ maxWidth: 360, margin: '0 auto', border: '1px dashed #ffffff22' }}>
3022
+ <StoryComponent />
3023
+ </div>
3024
+ ),
3025
+ ],
3026
+ play: async ({ canvasElement }) => {
3027
+ await assertPanelPropsFitNarrowWidth(canvasElement);
3028
+ },
3029
+ };
3030
+
2937
3031
  export const CompactReadOnly: Story = {
2938
3032
  parameters: {
2939
3033
  docs: {
@@ -3242,7 +3336,7 @@ export const CompactReadOnlyRichText: Story = {
3242
3336
  docs: {
3243
3337
  description: {
3244
3338
  story:
3245
- 'Renders the CompactBasic fixture with readOnly enabled — the Rich Text row reads as its text with its template chip and mounts no editor, the Template row reads as a chip showing the resolved template name (never the raw $local reference), and clicking either opens nothing.',
3339
+ 'Renders the CompactBasic fixture with readOnly enabled — the Rich Text row reads as its text with its template chip and mounts no editor, the Template row reads as a chip showing the resolved template name (never the raw $local reference), and clicking either opens nothing. Validation messages show as they do in an editable form.',
3246
3340
  },
3247
3341
  },
3248
3342
  chromatic: { disable: true },
@@ -3253,10 +3347,10 @@ export const CompactReadOnlyRichText: Story = {
3253
3347
  },
3254
3348
  play: async () => {
3255
3349
  await _testsWaitForText('Rich Text option');
3256
- // Editor verdicts stay in the editor: the fixture's invalid value and its
3257
- // dependency-locked field print no "fix this" hint in a read view.
3258
- await _testsWaitForTextToNotExist('Text value is empty');
3259
- await _testsWaitForTextToNotExist('dependencies are not fulfilled');
3350
+ // A read view says what is wrong with the record, as an editable one does:
3351
+ // the fixture's invalid value and its empty required fields say so.
3352
+ await _testsWaitForText('Text value is empty');
3353
+ await _testsWaitForText('This field is required');
3260
3354
 
3261
3355
  const row = (name: string) => document.querySelector(`.readfirst-row[data-field="${name}"]`);
3262
3356
  // Richtext: the text and its chip, no Slate.
@@ -3290,8 +3384,8 @@ export const CompactReadOnlyRichText: Story = {
3290
3384
  // Read mode as a fact sheet. Every kind of value the engine can hold, set,
3291
3385
  // plus one optional field nobody set (`notes`) and one required field nobody
3292
3386
  // set (`owner`): the first must not appear at all, the second appears unset
3293
- // with no amber. Every value is drawn in full in the row itself; nothing on
3294
- // the page is a control.
3387
+ // with no amber and says it is required. Every value is drawn in full in the
3388
+ // row itself; nothing on the page is a control.
3295
3389
  const ReadModeSchema: Record<string, TCompactField> = {
3296
3390
  id: {
3297
3391
  type: 'string',
@@ -3415,7 +3509,7 @@ export const CompactReadOnlyValues: Story = {
3415
3509
  docs: {
3416
3510
  description: {
3417
3511
  story:
3418
- 'Read-only as a fact sheet, not a disabled editor: no completion meter, no Needs attention / Set / Optional boxes, no required asterisks; an optional field nobody set is not listed, a required one nobody set reads as unset without the amber; every value is drawn in full in its row — long text wrapped under a Show more cap, the whole code block, every field of the hash, the chosen option with its description — and no row is a control: nothing is a button, and a click opens nothing.',
3512
+ 'Read-only as a fact sheet, not a disabled editor: no completion meter, no Needs attention / Set / Optional boxes, no required asterisks; an optional field nobody set is not listed, a required one nobody set reads as unset without the amber and says "This field is required"; every value is drawn in full in its row — long text wrapped under a Show more cap, the whole code block, every field of the hash, the chosen option with its description — and no row is a control: nothing is a button, and a click opens nothing.',
3419
3513
  },
3420
3514
  },
3421
3515
  chromatic: { disable: true },
@@ -3440,13 +3534,16 @@ export const CompactReadOnlyValues: Story = {
3440
3534
  await _testsWaitForTextToNotExist('Optional');
3441
3535
  // An optional field nobody set is not listed at all…
3442
3536
  await _testsWaitForTextToNotExist('Notes');
3443
- // …a required one nobody set is, as unset, with no amber dot.
3537
+ // …a required one nobody set is, as unset, with no amber dot, and says so.
3444
3538
  await _testsWaitForText('Owner');
3445
3539
  expect(
3446
3540
  document.querySelector(
3447
3541
  '.readfirst-row[data-field="owner"] .options-readfirst-statusdot-slot > *'
3448
3542
  )
3449
3543
  ).toBeNull();
3544
+ expect(document.querySelector('.readfirst-row[data-field="owner"]')?.textContent).toContain(
3545
+ 'This field is required'
3546
+ );
3450
3547
 
3451
3548
  const row = (name: string) =>
3452
3549
  document.querySelector(`.readfirst-row[data-field="${name}"]`) as HTMLElement;
@@ -9213,3 +9310,396 @@ export const CompactReadOnlyDataProvider: Story = {
9213
9310
  expect(row('name').querySelector('.options-readfirst-structured')).toBeNull();
9214
9311
  },
9215
9312
  };
9313
+
9314
+ /** The AI endpoint's Tools field as the Qorus IDE hands it to the form: a
9315
+ * `tool-catalog` editor storing tool SELECTORS, with the tool catalogue's
9316
+ * SOURCES (fetched by its `get_message`) hung on `allowed_values`. */
9317
+ const AiEndpointToolsSchema = {
9318
+ tools: {
9319
+ type: 'list',
9320
+ display_name: 'Tools',
9321
+ short_desc: 'Which tools the AI can call during chat',
9322
+ ui_type: 'tool-catalog',
9323
+ element_type: 'string',
9324
+ default_value: { type: 'tool-catalog', value: ['*'] },
9325
+ preselected: true,
9326
+ required: true,
9327
+ get_message: { action: 'creator-get-objects', object_type: 'tool-catalog' },
9328
+ return_message: {
9329
+ action: 'creator-return-objects',
9330
+ object_type: 'tool-catalog',
9331
+ return_value: 'objects',
9332
+ },
9333
+ allowed_values: [
9334
+ { display_name: 'Qorus system tools', value: { type: 'string', value: 'system' } },
9335
+ { display_name: 'salesforce-prod', value: { type: 'string', value: 'salesforce-prod' } },
9336
+ ],
9337
+ },
9338
+ channel: {
9339
+ type: 'string',
9340
+ display_name: 'Channel',
9341
+ required: true,
9342
+ allowed_values: [
9343
+ { display_name: 'Ops', value: { type: 'string', value: 'ops' } },
9344
+ { display_name: 'Dev', value: { type: 'string', value: 'dev' } },
9345
+ ],
9346
+ },
9347
+ };
9348
+
9349
+ export const ToolSelectorsAreNotJudgedAsChoices: Story = {
9350
+ parameters: {
9351
+ docs: {
9352
+ description: {
9353
+ story:
9354
+ "An AI endpoint's Tools field holds the server's default, `*` (all tools), and needs no attention: its tool-catalog editor stores selectors, and the catalogue sources listed beside it are not choices the value must be one of. The Channel field below has fixed choices and a value that is none of them, so it alone needs attention.",
9355
+ },
9356
+ },
9357
+ },
9358
+ args: {
9359
+ compact: true,
9360
+ minColumnWidth: '360px',
9361
+ options: AiEndpointToolsSchema as IOptionsSchema,
9362
+ value: {
9363
+ tools: { type: 'tool-catalog', value: ['*'] },
9364
+ channel: { type: 'string', value: 'gone' },
9365
+ },
9366
+ },
9367
+ play: async ({ args }) => {
9368
+ await _testsWaitForText('Tools');
9369
+ // The closed-choice field still flags its value...
9370
+ await _testsWaitForText('"gone" is not one of the choices');
9371
+ // ...and the selector is never reported as a value that is not a choice.
9372
+ await _testsWaitForTextToNotExist('"*" is not one of the choices');
9373
+ await waitFor(() => {
9374
+ const calls = (args.onValidityChange as ReturnType<typeof fn>).mock.calls;
9375
+ expect(calls.length).toBeGreaterThan(0);
9376
+ const [, data] = calls[calls.length - 1] as [boolean, IFormValidityData];
9377
+ expect(data.invalidFields.map((field) => field.fieldName)).toEqual(['channel']);
9378
+ });
9379
+ },
9380
+ };
9381
+
9382
+ const RequiredMessageSchema = {
9383
+ title: { type: 'string', display_name: 'Title', required: true },
9384
+ owner: { type: 'string', display_name: 'Owner', required: true },
9385
+ kind: {
9386
+ type: 'string',
9387
+ display_name: 'Kind',
9388
+ allowed_values: [
9389
+ { display_name: 'Inbound', value: { type: 'string', value: 'inbound' } },
9390
+ { display_name: 'Outbound', value: { type: 'string', value: 'outbound' } },
9391
+ ],
9392
+ },
9393
+ };
9394
+
9395
+ export const ANewFormSaysWhatIsRequired: Story = {
9396
+ parameters: {
9397
+ docs: {
9398
+ description: {
9399
+ story:
9400
+ 'A brand-new form with two empty required fields. Each says "This field is required" from the start, including the first one, which opens for editing, without waiting for the author to visit it.',
9401
+ },
9402
+ },
9403
+ },
9404
+ args: {
9405
+ compact: true,
9406
+ minColumnWidth: '360px',
9407
+ options: RequiredMessageSchema as IOptionsSchema,
9408
+ value: {},
9409
+ expandFirstRequired: true,
9410
+ },
9411
+ play: async ({ canvasElement }) => {
9412
+ await _testsWaitForText('Needs attention');
9413
+ await waitFor(() => {
9414
+ const title = canvasElement.querySelector('[data-field="title"]');
9415
+ const owner = canvasElement.querySelector('[data-field="owner"]');
9416
+ expect(title?.textContent).toContain('This field is required');
9417
+ expect(owner?.textContent).toContain('This field is required');
9418
+ });
9419
+ },
9420
+ };
9421
+
9422
+ export const AReadOnlyFormShowsWhatIsWrong: Story = {
9423
+ parameters: {
9424
+ docs: {
9425
+ description: {
9426
+ story:
9427
+ 'A read-only form shows the same validation messages as an editable one: the empty required Title and Owner say "This field is required", and Kind says its stored value is not one of the choices. Each empty value slot keeps its plain "—", so each requirement is stated once.',
9428
+ },
9429
+ },
9430
+ },
9431
+ args: {
9432
+ compact: true,
9433
+ readOnly: true,
9434
+ minColumnWidth: '360px',
9435
+ options: RequiredMessageSchema as IOptionsSchema,
9436
+ value: { kind: { type: 'string', value: 'sideways' } },
9437
+ },
9438
+ play: async ({ canvasElement }) => {
9439
+ await _testsWaitForText('"sideways" is not one of the choices');
9440
+ await waitFor(() => {
9441
+ const title = canvasElement.querySelector('[data-field="title"]');
9442
+ expect(title?.textContent).toContain('This field is required');
9443
+ expect(title?.textContent?.split('This field is required')).toHaveLength(2);
9444
+ expect(title?.querySelector('.options-readfirst-valuetext')?.textContent).toBe('—');
9445
+ expect(canvasElement.querySelector('[data-field="owner"]')?.textContent).toContain(
9446
+ 'This field is required'
9447
+ );
9448
+ });
9449
+ },
9450
+ };
9451
+
9452
+ /** A form taller than its 400px scroller: one empty required field, a required
9453
+ * Owner that is set, and a dozen set fields below them. */
9454
+ const MovedRowSchema: IOptionsSchema = {
9455
+ title: { type: 'string', display_name: 'Title', required: true },
9456
+ owner: { type: 'string', display_name: 'Owner', required: true },
9457
+ ...Object.fromEntries(
9458
+ Array.from({ length: 12 }, (_, index) => [
9459
+ `detail${index + 1}`,
9460
+ { type: 'string', display_name: `Detail ${index + 1}` },
9461
+ ])
9462
+ ),
9463
+ };
9464
+ const MovedRowValue: IOptions = {
9465
+ owner: { type: 'string', value: 'ops-team' },
9466
+ ...Object.fromEntries(
9467
+ Array.from({ length: 12 }, (_, index) => [
9468
+ `detail${index + 1}`,
9469
+ { type: 'string', value: `value ${index + 1}` },
9470
+ ])
9471
+ ),
9472
+ };
9473
+
9474
+ /** Renders the form with buttons that change a value from outside it, the way a
9475
+ * host's save or a server update does — which is what moves a collapsed row. */
9476
+ const MovedRowRender = ({ onChange, ...rest }: IFormEngineProps) => {
9477
+ const [val, setValue] = useState<IOptions>(MovedRowValue);
9478
+ return (
9479
+ <>
9480
+ <button type='button' onClick={() => setValue((v) => ({ ...v, title: { type: 'string', value: 'Quarterly report' } }))}>
9481
+ Fill title
9482
+ </button>
9483
+ <button type='button' onClick={() => setValue((v) => ({ ...v, owner: { type: 'string', value: '' } }))}>
9484
+ Clear owner
9485
+ </button>
9486
+ <div style={{ height: 400, overflow: 'hidden', display: 'flex', flexFlow: 'column' }}>
9487
+ <FormEngine
9488
+ {...rest}
9489
+ value={val}
9490
+ onChange={(_n, v, m) => {
9491
+ setValue(v as IOptions);
9492
+ onChange?.(_n, v, m);
9493
+ }}
9494
+ />
9495
+ </div>
9496
+ </>
9497
+ );
9498
+ };
9499
+
9500
+ /** Counts `scrollIntoView` calls on rows while a play runs. */
9501
+ const watchRowScrolls = () => {
9502
+ const original = HTMLElement.prototype.scrollIntoView;
9503
+ const scrolled: string[] = [];
9504
+ HTMLElement.prototype.scrollIntoView = function (this: HTMLElement, arg?: boolean | ScrollIntoViewOptions) {
9505
+ scrolled.push(this.getAttribute('data-field') || '');
9506
+ return original.call(this, arg);
9507
+ };
9508
+ return { scrolled, restore: () => (HTMLElement.prototype.scrollIntoView = original) };
9509
+ };
9510
+
9511
+ const movedRowScroller = () =>
9512
+ document.querySelector<HTMLElement>('.options-readfirst-scroll')!;
9513
+ const clickHostButton = (label: string) =>
9514
+ fireEvent.click([...document.querySelectorAll('button')].find((b) => b.textContent === label)!);
9515
+ const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
9516
+
9517
+ export const AMovedRowInViewIsNotScrolled: Story = {
9518
+ parameters: {
9519
+ docs: {
9520
+ description: {
9521
+ story:
9522
+ 'Title is empty, so it sits under Needs attention. "Fill title" sets it from outside the form, and the row moves into Set, where it is already in plain view. The form follows the row with a flash but does not scroll, because nothing needs bringing into view.',
9523
+ },
9524
+ },
9525
+ },
9526
+ render: MovedRowRender,
9527
+ args: {
9528
+ compact: true,
9529
+ compactScroll: 'own' as const,
9530
+ minColumnWidth: '300px',
9531
+ options: MovedRowSchema,
9532
+ },
9533
+ play: async () => {
9534
+ await _testsWaitForText('Needs attention');
9535
+ const watch = watchRowScrolls();
9536
+ try {
9537
+ const scroller = movedRowScroller();
9538
+ const before = scroller.scrollTop;
9539
+ clickHostButton('Fill title');
9540
+ // The move is followed by a flash in the frame the reveal runs in: wait
9541
+ // for it, then one more frame so the reveal has certainly run.
9542
+ await waitFor(() =>
9543
+ expect(document.querySelector('.readfirst-row-flash[data-field="title"]')).toBeTruthy()
9544
+ );
9545
+ await nextFrame();
9546
+ expect(watch.scrolled).not.toContain('title');
9547
+ expect(scroller.scrollTop).toBe(before);
9548
+ } finally {
9549
+ watch.restore();
9550
+ }
9551
+ },
9552
+ };
9553
+
9554
+ export const AMovedRowLandsClearOfThePinnedHeader: Story = {
9555
+ parameters: {
9556
+ docs: {
9557
+ description: {
9558
+ story:
9559
+ 'The form is scrolled to its end, then "Clear owner" empties Owner, so its row moves up into Needs attention, out of view. The form scrolls the least distance that shows it, and the row stops just below the pinned "Needs attention" header instead of underneath it.',
9560
+ },
9561
+ },
9562
+ },
9563
+ render: MovedRowRender,
9564
+ args: {
9565
+ compact: true,
9566
+ compactScroll: 'own' as const,
9567
+ minColumnWidth: '300px',
9568
+ options: MovedRowSchema,
9569
+ },
9570
+ play: async () => {
9571
+ await _testsWaitForText('Detail 12');
9572
+ const scroller = movedRowScroller();
9573
+ const scrollEnd = () =>
9574
+ new Promise<void>((resolve) =>
9575
+ scroller.addEventListener('scrollend', () => resolve(), { once: true })
9576
+ );
9577
+ // Even an instant scroll ends with a `scrollend`; consume it here, or it
9578
+ // would be taken for the end of the reveal below.
9579
+ const atBottom = scrollEnd();
9580
+ scroller.scrollTop = scroller.scrollHeight;
9581
+ await atBottom;
9582
+ expect(scroller.scrollTop).toBeGreaterThan(0);
9583
+ const watch = watchRowScrolls();
9584
+ try {
9585
+ // The reveal is a smooth scroll; its end is the `scrollend` event, armed
9586
+ // before the click so it cannot be missed.
9587
+ const scrolled = scrollEnd();
9588
+ clickHostButton('Clear owner');
9589
+ await waitFor(() => expect(watch.scrolled).toContain('owner'));
9590
+ await scrolled;
9591
+ const row = document.querySelector<HTMLElement>('.readfirst-row[data-field="owner"]')!;
9592
+ const attention = [...document.querySelectorAll<HTMLElement>('.options-readfirst-group')].find(
9593
+ (group) => (group.textContent || '').startsWith('Needs attention')
9594
+ )!;
9595
+ const header = attention.querySelector<HTMLElement>(':scope > .reqore-panel-title')!;
9596
+ const rowRect = row.getBoundingClientRect();
9597
+ const viewRect = scroller.getBoundingClientRect();
9598
+ // Clear of the pinned header — and right below it, the least scroll that
9599
+ // shows the row, not centred in the scroller — and inside the scroller.
9600
+ const headerBottom = header.getBoundingClientRect().bottom;
9601
+ expect(rowRect.top).toBeGreaterThanOrEqual(headerBottom - 1);
9602
+ expect(rowRect.top).toBeLessThanOrEqual(headerBottom + 2);
9603
+ expect(rowRect.bottom).toBeLessThanOrEqual(viewRect.bottom + 1);
9604
+ } finally {
9605
+ watch.restore();
9606
+ }
9607
+ },
9608
+ };
9609
+
9610
+ /**
9611
+ * Scrolls the form so the end of its Needs attention box passes up through the
9612
+ * pinned toolbar, and asserts at each step that the toolbar stays on top: the
9613
+ * box header pinned below it is pushed up as its box scrolls away, and must
9614
+ * slide UNDER the toolbar, never over it. Each step waits on `scrollend`.
9615
+ */
9616
+ const assertBoxHeaderSlidesUnderToolbar = async (scroller: HTMLElement) => {
9617
+ await _testsWaitForText('Needs attention');
9618
+ const toolbar = document.querySelector<HTMLElement>(
9619
+ '.options-readfirst-scroll > .reqore-panel > .reqore-panel-title'
9620
+ )!;
9621
+ const box = [...document.querySelectorAll<HTMLElement>('.options-readfirst-group')].find(
9622
+ (group) => (group.textContent || '').startsWith('Needs attention')
9623
+ )!;
9624
+ const header = box.querySelector<HTMLElement>(':scope > .reqore-panel-title')!;
9625
+ const scrollBy = async (delta: number) => {
9626
+ const ended = new Promise<void>((resolve) =>
9627
+ scroller.addEventListener('scrollend', () => resolve(), { once: true })
9628
+ );
9629
+ scroller.scrollTop += delta;
9630
+ await ended;
9631
+ };
9632
+ // Box fully in view first: its header sits clear below the toolbar.
9633
+ expect(header.getBoundingClientRect().top).toBeGreaterThanOrEqual(
9634
+ toolbar.getBoundingClientRect().bottom - 1
9635
+ );
9636
+ // Bring the end of the box up to the toolbar's bottom edge, then past it.
9637
+ await scrollBy(box.getBoundingClientRect().bottom - toolbar.getBoundingClientRect().bottom);
9638
+ for (const step of [12, 12, 12]) {
9639
+ await scrollBy(step);
9640
+ const toolbarRect = toolbar.getBoundingClientRect();
9641
+ const headerRect = header.getBoundingClientRect();
9642
+ // Where the pushed-up header and the toolbar overlap, the toolbar is on top.
9643
+ const overlapTop = Math.max(toolbarRect.top, headerRect.top);
9644
+ const overlapBottom = Math.min(toolbarRect.bottom, headerRect.bottom);
9645
+ expect(overlapBottom).toBeGreaterThan(overlapTop);
9646
+ const hit = document.elementFromPoint(
9647
+ toolbarRect.left + toolbarRect.width / 2,
9648
+ (overlapTop + overlapBottom) / 2
9649
+ );
9650
+ expect(toolbar.contains(hit), 'the toolbar is painted over the box header').toBe(true);
9651
+ }
9652
+ };
9653
+
9654
+ const StickyBoxesSchema: IOptionsSchema = {
9655
+ ...MovedRowSchema,
9656
+ summary: { type: 'string', display_name: 'Summary', required: true },
9657
+ reviewer: { type: 'string', display_name: 'Reviewer', required: true },
9658
+ };
9659
+
9660
+ const stickyBoxesStory = (layout: 'own' | 'host' | 'padded-host'): Story => ({
9661
+ parameters: {
9662
+ docs: {
9663
+ description: {
9664
+ story: `${
9665
+ layout === 'own' ? 'The form owns its scroller'
9666
+ : layout === 'host' ? 'The host scrolls the form'
9667
+ : 'A padded host scrolls the form'
9668
+ }. Scrolling the end of the Needs attention box up past the pinned toolbar pushes the box's pinned header up with it, and the header slides under the toolbar: the toolbar stays fully visible.`,
9669
+ },
9670
+ },
9671
+ },
9672
+ render: ({ value, ...rest }: IFormEngineProps) => (
9673
+ <div
9674
+ data-testid='sticky-host'
9675
+ style={{
9676
+ height: 420,
9677
+ overflow: layout === 'own' ? 'hidden' : 'auto',
9678
+ padding: layout === 'padded-host' ? 20 : 0,
9679
+ display: 'flex',
9680
+ flexFlow: 'column',
9681
+ }}
9682
+ >
9683
+ <FormEngine {...rest} value={value} />
9684
+ </div>
9685
+ ),
9686
+ args: {
9687
+ compact: true,
9688
+ compactScroll: layout === 'own' ? 'own' : 'host',
9689
+ minColumnWidth: '300px',
9690
+ options: StickyBoxesSchema,
9691
+ value: MovedRowValue,
9692
+ },
9693
+ play: async () => {
9694
+ await _testsWaitForText('Detail 12');
9695
+ const scroller =
9696
+ layout === 'own' ?
9697
+ document.querySelector<HTMLElement>('.options-readfirst-scroll')!
9698
+ : document.querySelector<HTMLElement>('[data-testid="sticky-host"]')!;
9699
+ await assertBoxHeaderSlidesUnderToolbar(scroller);
9700
+ },
9701
+ });
9702
+
9703
+ export const BoxHeaderSlidesUnderToolbarOwnScroll: Story = stickyBoxesStory('own');
9704
+ export const BoxHeaderSlidesUnderToolbarHostScroll: Story = stickyBoxesStory('host');
9705
+ export const BoxHeaderSlidesUnderToolbarPaddedHost: Story = stickyBoxesStory('padded-host');
@@ -42,6 +42,7 @@ import { shouldMarkAsExpression } from '../expressions/argumentPresence';
42
42
  import { offersTypeChoices } from './typeChoices';
43
43
  import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions';
44
44
  import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
45
+ import { revealRow } from './revealRow';
45
46
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last, uniq } from 'lodash';
46
47
  import map from 'lodash/map';
47
48
  import reduce from 'lodash/reduce';
@@ -1520,8 +1521,15 @@ const FormEngineImpl = ({
1520
1521
  const target = compactWrapNodeRef.current?.querySelector<HTMLElement>(
1521
1522
  `.readfirst-row[data-field="${optionNames[0]}"]`
1522
1523
  );
1523
- if (typeof target?.scrollIntoView === 'function') {
1524
- target.scrollIntoView({ block: 'center', behavior: 'smooth' });
1524
+ // Only when it is not already in view, and never under a pinned box
1525
+ // header: `block: 'center'` scrolled a row that was in plain sight and
1526
+ // centred it against the whole scrollport, so the header pinned at the
1527
+ // top was drawn over it (see `revealRow`).
1528
+ if (target) {
1529
+ revealRow(
1530
+ target,
1531
+ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth'
1532
+ );
1525
1533
  }
1526
1534
  });
1527
1535
  }
@@ -1804,28 +1812,8 @@ const FormEngineImpl = ({
1804
1812
  setLocalValue?.({ fields: fixedValue, meta: undefined });
1805
1813
  }, [JSON.stringify(options), JSON.stringify(value), isRendererOnly]);
1806
1814
 
1807
- /**
1808
- * Fields the reader has actually edited in this instance.
1809
- *
1810
- * "This field is required" is an ERROR message, and an error is a report that
1811
- * something went wrong. Under a field nobody has been in yet it reports
1812
- * nothing — the form is empty because it is new — while the requirement is
1813
- * already stated by the asterisk, by the Needs-attention box the row sits in,
1814
- * and by the completion meter. A form whose first act is to accuse the reader
1815
- * of a mistake they have not made teaches them to discount its warnings.
1816
- *
1817
- * So the message waits for a touch. Being IN the field and leaving it empty
1818
- * is a real gap, and that one shows. A ref, not state: every edit re-renders
1819
- * anyway, so the following render reads the current set with no second pass.
1820
- */
1821
- const touchedOptionsRef = useRef<Set<string>>(new Set());
1822
-
1823
1815
  const handleValueChange = useCallback(
1824
1816
  (optionName: string, val?: any, _type?: string, isFunction?: boolean) => {
1825
- // Every route into this handler is a person acting: typing, picking from a
1826
- // menu, clearing the value, adding an optional field. Marked here rather
1827
- // than inside the updater below, which React may invoke twice.
1828
- touchedOptionsRef.current.add(optionName);
1829
1817
  setLocalValue(({ fields = {} }) => {
1830
1818
  const schemaType = getOptionSchemaStorageType(options?.[optionName], isRendererOnly);
1831
1819
  const isAnyLike = isUntypedOptionType(schemaType);
@@ -2326,6 +2314,9 @@ const FormEngineImpl = ({
2326
2314
  // The expression flag lives on the field value, not the schema — so an
2327
2315
  // expression value is validated as an expression, not the base type.
2328
2316
  isFunction: (availableOptions?.[optionName] as { is_expression?: boolean })?.is_expression,
2317
+ // A field drawn by a bespoke editor (the consumer's own included) is
2318
+ // not judged against `allowed_values` as fixed choices.
2319
+ hasOwnEditor: isRendererOnly(options?.[optionName]?.ui_type as TQorusType),
2329
2320
  } as any);
2330
2321
  },
2331
2322
  [
@@ -2333,6 +2324,7 @@ const FormEngineImpl = ({
2333
2324
  JSON.stringify(availableOptions),
2334
2325
  JSON.stringify(localValue.fields),
2335
2326
  dependencyLockedNames,
2327
+ isRendererOnly,
2336
2328
  ]
2337
2329
  );
2338
2330
 
@@ -2377,6 +2369,7 @@ const FormEngineImpl = ({
2377
2369
  ...options?.[optionName],
2378
2370
  // The expression flag lives on the field value, not the schema.
2379
2371
  isFunction: (option as { is_expression?: boolean }).is_expression,
2372
+ hasOwnEditor: isRendererOnly(options?.[optionName]?.ui_type as TQorusType),
2380
2373
  } as any);
2381
2374
  }
2382
2375
 
@@ -3154,7 +3147,7 @@ const FormEngineImpl = ({
3154
3147
  name={optionName}
3155
3148
  option={{ type: resolvedType, ...other }}
3156
3149
  getType={getTypeForOption}
3157
- untouched={!touchedOptionsRef.current.has(optionName)}
3150
+ isRendererOnly={isRendererOnly}
3158
3151
  />
3159
3152
  {operators && size(operators) && size(other.op) ?
3160
3153
  <>
@@ -3266,6 +3259,7 @@ const FormEngineImpl = ({
3266
3259
  handleOptionLabelClick,
3267
3260
  removeSelectedOption,
3268
3261
  getTypeForOption,
3262
+ isRendererOnly,
3269
3263
  isOptionValid,
3270
3264
  confirmAction,
3271
3265
  optionActions,
@@ -3313,6 +3307,7 @@ const FormEngineImpl = ({
3313
3307
  handleOptionLabelClick,
3314
3308
  removeSelectedOption,
3315
3309
  getTypeForOption,
3310
+ isRendererOnly,
3316
3311
  isOptionValid,
3317
3312
  confirmAction,
3318
3313
  optionActions,
@@ -3839,6 +3834,16 @@ const FormEngineImpl = ({
3839
3834
  // Keeps the wrapper from clipping, which is what a pinned box
3840
3835
  // header inside it needs — see StyledCompactPanel.
3841
3836
  $stickyBoxes={compactStickyBoxHeaders && !compactNested}
3837
+ // A narrow header the consumer dressed stacks its toolbar
3838
+ // under the label and actions (see StyledCompactPanel).
3839
+ $stackHeader={
3840
+ compactNarrow &&
3841
+ !!(
3842
+ compactPanelProps?.label ||
3843
+ compactPanelProps?.icon ||
3844
+ compactPanelProps?.actions?.length
3845
+ )
3846
+ }
3842
3847
  flat
3843
3848
  raised
3844
3849
  minimal