@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
@@ -327,20 +327,13 @@ export interface IOptionFieldMessagesProps {
327
327
  name: string;
328
328
  getType: (type: string) => string;
329
329
  /**
330
- * The reader has not edited this field in this session.
330
+ * Whether a `ui_type` names a bespoke editor, the consumer's own included.
331
331
  *
332
- * An error is a report that something went WRONG. "This field is required"
333
- * under a field nobody has been in yet reports nothing: the form is empty
334
- * because it is new. The requirement is already carried three other ways —
335
- * the asterisk on the label, the Needs-attention box the row sits in, and the
336
- * completion meter — so the message waits until the reader has been in the
337
- * field and left it empty, which IS a thing that went wrong.
338
- *
339
- * Only the plain required message is held back. A value that fails
340
- * validation, an unmet required GROUP and a locked dependency are all facts
341
- * about what the form currently holds, and they show immediately.
332
+ * Such a field's `allowed_values` are not fixed choices (the editor decides
333
+ * what a value means), so a value is not judged against them. Without it only
334
+ * reqraft's built-in editors are known.
342
335
  */
343
- untouched?: boolean;
336
+ isRendererOnly?: (type?: any) => boolean;
344
337
  }
345
338
 
346
339
  /**
@@ -354,7 +347,7 @@ export const getOptionFieldMessages = ({
354
347
  name,
355
348
  allOptions,
356
349
  getType,
357
- untouched,
350
+ isRendererOnly,
358
351
  }: IOptionFieldMessagesProps): IReqoreTagProps[] => {
359
352
  const optionSchema = schema[name];
360
353
  const result: IReqoreTagProps[] = [];
@@ -386,6 +379,7 @@ export const getOptionFieldMessages = ({
386
379
  // config never reached the validator.
387
380
  ...(optionSchema as object),
388
381
  optionSchema: schema,
382
+ hasOwnEditor: !!isRendererOnly?.(optionSchema?.ui_type),
389
383
  }
390
384
  );
391
385
  if (!validationData.isValid) {
@@ -395,9 +389,10 @@ export const getOptionFieldMessages = ({
395
389
  });
396
390
  }
397
391
  } else {
398
- // A locked field is not asked for: the dependency message below says what
392
+ // An empty required field says so from the start, on a new form too. A
393
+ // locked field is not asked for: the dependency message below says what
399
394
  // unlocks it, and until then there is nothing to fill in.
400
- if (optionSchema?.required && !untouched && !locked) {
395
+ if (optionSchema?.required && !locked) {
401
396
  result.push({ label: 'This field is required', intent: 'danger' });
402
397
  }
403
398
 
@@ -442,11 +437,25 @@ export const OptionFieldMessages = ({
442
437
  name,
443
438
  allOptions,
444
439
  getType,
445
- untouched,
440
+ isRendererOnly,
446
441
  }: IOptionFieldMessagesProps) => {
447
442
  const messages: IReqoreTagProps[] = useMemo(
448
- () => getOptionFieldMessages({ schema, option, name, allOptions, getType, untouched }),
449
- [JSON.stringify(schema), JSON.stringify(option), JSON.stringify(allOptions), name, untouched]
443
+ () =>
444
+ getOptionFieldMessages({
445
+ schema,
446
+ option,
447
+ name,
448
+ allOptions,
449
+ getType,
450
+ isRendererOnly,
451
+ }),
452
+ [
453
+ JSON.stringify(schema),
454
+ JSON.stringify(option),
455
+ JSON.stringify(allOptions),
456
+ name,
457
+ isRendererOnly,
458
+ ]
450
459
  );
451
460
 
452
461
  if (!size(messages)) {
@@ -98,6 +98,8 @@ export interface ICompactRowContext {
98
98
  handleOptionLabelClick: (optionName: string) => void;
99
99
  removeSelectedOption: (optionName: string) => void;
100
100
  getTypeForOption: (type: string) => string;
101
+ /** Whether a `ui_type` names a bespoke editor, reqraft's or the consumer's own. */
102
+ isRendererOnly?: (type?: any) => boolean;
101
103
  isOptionValid: (optionName: string, type: any, optionValue: any) => boolean;
102
104
  confirmAction: ReturnType<typeof useReqoreProperty<'confirmAction'>>;
103
105
 
@@ -66,6 +66,9 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
66
66
  $tightHeader?: boolean;
67
67
  /** True when the status boxes inside pin their headers. */
68
68
  $stickyBoxes?: boolean;
69
+ /** True when a narrow form's header carries a consumer's label, icon or
70
+ * actions beside the toolbar, which then has to take a line of its own. */
71
+ $stackHeader?: boolean;
69
72
  }>`
70
73
  /* The wrapper must not clip, or nothing INSIDE it can pin.
71
74
 
@@ -89,6 +92,37 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
89
92
  }
90
93
  `
91
94
  : ''}
95
+ /* A NARROW header dressed by the consumer (\`compactPanelProps\` label, icon
96
+ or actions) stacks: the label and the consumer's actions share the first
97
+ line, and the toolbar takes the whole second line.
98
+
99
+ ReqorePanel lays its header out as one unwrapping row — the label, then an
100
+ action group that never shrinks. The toolbar is one of those actions and
101
+ is as wide as the form, so at phone width the label was squeezed to
102
+ nothing and the consumer's own action was pushed past the panel's edge and
103
+ clipped. Flattening the action group (\`display: contents\`) lets its
104
+ members wrap with the label, and \`order\` moves the toolbar after them. */
105
+ ${({ $stackHeader }) =>
106
+ $stackHeader ?
107
+ css`
108
+ && > .reqore-panel-title {
109
+ flex-wrap: wrap;
110
+ row-gap: ${GAP_FROM_SIZE.normal}px;
111
+ }
112
+ && > .reqore-panel-title > .reqore-panel-title-header {
113
+ flex: 1 1 0;
114
+ min-width: 0;
115
+ }
116
+ && > .reqore-panel-title > .reqore-control-group {
117
+ display: contents;
118
+ }
119
+ && > .reqore-panel-title .options-readfirst-toolbar {
120
+ order: 1;
121
+ flex: 1 1 100%;
122
+ min-width: 0;
123
+ }
124
+ `
125
+ : ''}
92
126
  > .reqore-panel-title {
93
127
  /* The blur + translateZ exist only to make the STICKY top-level toolbar ghost
94
128
  content beneath it; a nested sub-form's header isn't sticky, so skip them
@@ -97,6 +131,14 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
97
131
  $nested ? '' : (
98
132
  'backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transform: translateZ(0);'
99
133
  )}
134
+ /* Above the status-box headers, which pin just below this toolbar. A
135
+ pinned header cannot leave its own box, so when the end of a box
136
+ scrolls up past the toolbar it pushes the header up with it — and with
137
+ both at ReqorePanel's sticky z-index the header, later in the
138
+ document, slid OVER the toolbar and covered the search and the
139
+ meter. One step higher and it slides under the toolbar's blur
140
+ instead. Nested sub-forms pin nothing, so they keep the default. */
141
+ ${({ $nested }) => ($nested ? '' : 'z-index: 3;')}
100
142
  padding-top: ${GAP_FROM_SIZE[HEADER_GAP]}px;
101
143
  /* A header with a SEARCH ROW keeps the \`big\` gap below it — a tall
102
144
  interactive control earns separation from the content it filters. A
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Bring a form row into view only when it is not already there, and never
3
+ * under a pinned header.
4
+ *
5
+ * A row that moves between status boxes (filling a field moves it from "Needs
6
+ * attention" to "Set") is followed so the reader does not lose it. That used
7
+ * to be `scrollIntoView({ block: 'center' })`, which scrolls even when the row
8
+ * is already in plain sight, and centres it against the whole scrollport while
9
+ * the box header pinned at the top hides part of it. In the qorus-ide schema
10
+ * editor the moved row landed with the "Needs attention" header (375-423)
11
+ * drawn over it (405).
12
+ *
13
+ * The visible area is the scrollport minus the pinned chrome above the row:
14
+ * the form's sticky toolbar and the sticky header of every status box the row
15
+ * sits in. Both are read from the DOM when the reveal runs, so a host's own
16
+ * offset, a toolbar that wraps, or a header that is not pinned at all are all
17
+ * accounted for without the caller knowing about them.
18
+ */
19
+
20
+ /** Selectors of the elements whose header may be pinned above a row. */
21
+ const PINNED_HEADER_OWNERS = '.options-readfirst-group, .options-readfirst-scroll > .reqore-panel';
22
+
23
+ const isScrollable = (element: HTMLElement): boolean => {
24
+ const { overflowY } = getComputedStyle(element);
25
+ return (
26
+ (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
27
+ element.scrollHeight > element.clientHeight
28
+ );
29
+ };
30
+
31
+ /** The nearest ancestor that actually scrolls; `undefined` means the viewport. */
32
+ export const findScrollContainer = (element: HTMLElement): HTMLElement | undefined => {
33
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
34
+ if (parent === document.body || parent === document.documentElement) {
35
+ return undefined;
36
+ }
37
+ if (isScrollable(parent)) {
38
+ return parent;
39
+ }
40
+ }
41
+ return undefined;
42
+ };
43
+
44
+ /**
45
+ * How far below the top of the scrollport the pinned chrome above `element`
46
+ * reaches: the largest `top + height` of any sticky header of an enclosing
47
+ * status box or form toolbar. Zero when nothing above it is pinned.
48
+ */
49
+ export const getPinnedChromeHeight = (element: HTMLElement): number => {
50
+ let clearance = 0;
51
+ for (
52
+ let owner = element.parentElement?.closest<HTMLElement>(PINNED_HEADER_OWNERS);
53
+ owner;
54
+ owner = owner.parentElement?.closest<HTMLElement>(PINNED_HEADER_OWNERS)
55
+ ) {
56
+ const header = owner.querySelector<HTMLElement>(':scope > .reqore-panel-title');
57
+ if (!header || header.contains(element)) {
58
+ continue;
59
+ }
60
+ const style = getComputedStyle(header);
61
+ if (style.position !== 'sticky') {
62
+ continue;
63
+ }
64
+ const top = parseFloat(style.top) || 0;
65
+ clearance = Math.max(clearance, top + header.getBoundingClientRect().height);
66
+ }
67
+ return clearance;
68
+ };
69
+
70
+ /**
71
+ * How far below the top edge of the row's scrollport the pinned chrome reaches.
72
+ *
73
+ * A sticky `top` is measured from the scrollport's top edge INSET BY ITS
74
+ * PADDING, while `scrollIntoView` and `scroll-margin-top` align against the
75
+ * edge itself. Leaving the padding out put an opened row 16px under its box
76
+ * header inside a padded host scroller (the Storybook canvas, and any panel
77
+ * with padding).
78
+ */
79
+ export const getRevealClearance = (element: HTMLElement): number => {
80
+ const container = findScrollContainer(element);
81
+ const inset = container ? parseFloat(getComputedStyle(container).paddingTop) || 0 : 0;
82
+ return inset + getPinnedChromeHeight(element);
83
+ };
84
+
85
+ /**
86
+ * Whether the whole of `element` is visible below `clearance` (measured from
87
+ * the top edge of its scrollport, see {@link getRevealClearance}).
88
+ */
89
+ export const isRowInView = (element: HTMLElement, clearance: number): boolean => {
90
+ const container = findScrollContainer(element);
91
+ const viewTop = container ? container.getBoundingClientRect().top + container.clientTop : 0;
92
+ const viewBottom =
93
+ container ?
94
+ viewTop + container.clientHeight
95
+ : window.innerHeight || document.documentElement.clientHeight;
96
+ const rect = element.getBoundingClientRect();
97
+ return rect.top >= viewTop + clearance && rect.bottom <= viewBottom;
98
+ };
99
+
100
+ /**
101
+ * Scroll `element` the least distance that shows it clear of the pinned chrome
102
+ * above it — and not at all when it is already fully in view.
103
+ *
104
+ * `scroll-margin-top` is what keeps a row that has to come down from above from
105
+ * stopping under the header: `block: 'nearest'` aligns the row's margin box,
106
+ * so the row itself stops exactly below the pinned chrome.
107
+ *
108
+ * Returns whether a scroll was started.
109
+ */
110
+ export const revealRow = (element: HTMLElement, behavior: ScrollBehavior = 'smooth'): boolean => {
111
+ // jsdom implements neither layout nor `scrollIntoView`; a missing reveal must
112
+ // never break a form.
113
+ if (typeof element.scrollIntoView !== 'function') {
114
+ return false;
115
+ }
116
+ const clearance = getRevealClearance(element);
117
+ if (isRowInView(element, clearance)) {
118
+ return false;
119
+ }
120
+ element.style.scrollMarginTop = `${clearance}px`;
121
+ element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior });
122
+ return true;
123
+ };
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit';
11
11
  import isEqual from 'lodash/isEqual';
12
+ import { isRendererOnlyUiType } from '../components/form/engine/rendererTypes';
12
13
  import { richtextHasTag, richtextToString } from './common';
13
14
  import { getListElementValue } from './options';
14
15
  import { isValueTemplate } from './templates';
@@ -109,8 +110,32 @@ export interface IChoiceFieldSchema {
109
110
  /** Set when the value is an expression, which is resolved at run time. */
110
111
  isFunction?: boolean;
111
112
  is_expression?: boolean;
113
+ /** The editor the field is drawn with; see {@link hasOwnEditor}. */
114
+ ui_type?: string | readonly string[];
115
+ /**
116
+ * Set by a form that knows the field is drawn by a bespoke editor of the
117
+ * consumer's own (one it declared in `rendererOnlyUiTypes`), which reqraft's
118
+ * built-in list cannot name.
119
+ */
120
+ hasOwnEditor?: boolean;
112
121
  }
113
122
 
123
+ /**
124
+ * Does a bespoke editor, rather than reqraft's choice picker, draw this field?
125
+ *
126
+ * Then the editor alone decides what a value means, and `allowed_values` is not
127
+ * a closed set of values the field may hold. The AI endpoint's Tools field is
128
+ * the case that proved it: its `tool-catalog` editor stores SELECTORS -- `*` for
129
+ * every tool, `system:*`, a connection name, `connection/tool` -- and the host
130
+ * hangs the tool catalogue's SOURCES on `allowed_values` for the editor to
131
+ * browse. Judging the selectors against the sources flagged the server's own
132
+ * default, `["*"]`, as "not one of the choices", so no chat endpoint could be
133
+ * published with it. The server says the same: a custom `ui_type` "defines its
134
+ * own value shape".
135
+ */
136
+ const hasOwnEditor = (field: IChoiceFieldSchema): boolean =>
137
+ !!field.hasOwnEditor || isRendererOnlyUiType(field.ui_type);
138
+
114
139
  /**
115
140
  * The parts of a stored value that are not among the field's fixed choices.
116
141
  *
@@ -121,14 +146,22 @@ export interface IChoiceFieldSchema {
121
146
  * all of which are resolved when the interface runs;
122
147
  * - `allowed_values: []` — the server has no choices to offer right now, which
123
148
  * is "unknown", not "nothing is allowed";
124
- * - creatable choices, where any value may be added.
149
+ * - creatable choices, where any value may be added;
150
+ * - a field drawn by a bespoke editor (a renderer-only `ui_type` such as
151
+ * `tool-catalog`), whose values are whatever that editor says they are.
125
152
  *
126
153
  * A single-valued field is judged as a whole. A multi-select field, and a list
127
154
  * whose elements are constrained by `element_allowed_values`, are judged per
128
155
  * element, so the answer names exactly the elements that are not choices.
129
156
  */
130
157
  export const getUnlistedChoices = (value: unknown, field?: IChoiceFieldSchema): unknown[] => {
131
- if (!field || isEmptyChoiceValue(value) || field.isFunction || field.is_expression) {
158
+ if (
159
+ !field ||
160
+ isEmptyChoiceValue(value) ||
161
+ field.isFunction ||
162
+ field.is_expression ||
163
+ hasOwnEditor(field)
164
+ ) {
132
165
  return [];
133
166
  }
134
167
 
@@ -282,3 +282,39 @@ export async function _testsDoubleClickButton({
282
282
  }
283
283
  console.log('Clicked button:', label, selector, nth, wait);
284
284
  }
285
+
286
+ /**
287
+ * Run `body` with the story's own viewport narrowed to `width` x `height`, then
288
+ * put it back.
289
+ *
290
+ * qlip's `parameters.qlip.viewport` sizes the CAPTURE, which happens after the
291
+ * play has finished — the play itself runs at the test browser's width. A play
292
+ * that asserts the phone layout has to narrow the viewport itself, with the
293
+ * same `page.viewport` call qlip makes. It is a DYNAMIC import: `vitest/browser`
294
+ * throws on import outside Browser Mode, so a static one would take the
295
+ * Storybook dev server down. Where the call is unavailable, `body` runs at the
296
+ * width it already had and is told so (`narrowed` is false).
297
+ */
298
+ export const _testsWithViewport = async (
299
+ width: number,
300
+ height: number,
301
+ body: (narrowed: boolean) => Promise<void>
302
+ ) => {
303
+ let resize: ((w: number, h: number) => Promise<void>) | undefined;
304
+ try {
305
+ ({
306
+ page: { viewport: resize },
307
+ } = (await import('vitest/browser')) as never);
308
+ } catch {
309
+ resize = undefined;
310
+ }
311
+
312
+ const was = { width: window.innerWidth, height: window.innerHeight };
313
+ await resize?.(width, height);
314
+ try {
315
+ await body(!!resize && window.innerWidth === width);
316
+ } finally {
317
+ // Unconditional: a viewport left narrowed would narrow every later story.
318
+ await resize?.(was.width, was.height);
319
+ }
320
+ };