@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.
- package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +18 -14
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
- package/dist/components/form/engine/CompactToolbar.js +1 -1
- package/dist/components/form/engine/CompactToolbar.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +24 -29
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.d.ts +7 -14
- package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.js +22 -6
- package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
- package/dist/components/form/engine/compactRowContext.d.ts +2 -0
- package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowContext.js.map +1 -1
- package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowStyles.js +33 -27
- package/dist/components/form/engine/compactRowStyles.js.map +1 -1
- package/dist/components/form/engine/revealRow.d.ts +53 -0
- package/dist/components/form/engine/revealRow.d.ts.map +1 -0
- package/dist/components/form/engine/revealRow.js +120 -0
- package/dist/components/form/engine/revealRow.js.map +1 -0
- package/dist/helpers/allowedValues.d.ts +11 -1
- package/dist/helpers/allowedValues.d.ts.map +1 -1
- package/dist/helpers/allowedValues.js +25 -2
- package/dist/helpers/allowedValues.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +21 -19
- package/src/components/form/engine/CompactToolbar.tsx +8 -1
- package/src/components/form/engine/FormEngine.stories.tsx +499 -9
- package/src/components/form/engine/FormEngine.tsx +28 -23
- package/src/components/form/engine/OptionFieldMessages.tsx +27 -18
- package/src/components/form/engine/compactRowContext.ts +2 -0
- package/src/components/form/engine/compactRowStyles.ts +42 -0
- package/src/components/form/engine/revealRow.ts +123 -0
- package/src/helpers/allowedValues.ts +35 -2
- 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
|
-
//
|
|
3257
|
-
//
|
|
3258
|
-
await
|
|
3259
|
-
await
|
|
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
|
|
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
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
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
|