@qoretechnologies/reqraft 0.10.16 → 0.10.18

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 (43) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +82 -3
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/FormEngine.d.ts +37 -11
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +187 -116
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/compactRowContext.d.ts +8 -0
  9. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  10. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  11. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  12. package/dist/components/form/engine/compactRowStyles.js +1 -1
  13. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  14. package/dist/components/form/engine/optionActions.d.ts +28 -0
  15. package/dist/components/form/engine/optionActions.d.ts.map +1 -0
  16. package/dist/components/form/engine/optionActions.js +18 -0
  17. package/dist/components/form/engine/optionActions.js.map +1 -0
  18. package/dist/components/form/engine/readFirst.d.ts +9 -2
  19. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  20. package/dist/components/form/engine/readFirst.js +15 -3
  21. package/dist/components/form/engine/readFirst.js.map +1 -1
  22. package/dist/components/form/engine/rendererTypes.d.ts +35 -0
  23. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -0
  24. package/dist/components/form/engine/rendererTypes.js +63 -0
  25. package/dist/components/form/engine/rendererTypes.js.map +1 -0
  26. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  27. package/dist/components/form/fields/auto/AutoFormField.js +6 -1
  28. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  29. package/dist/components/form/index.d.ts +2 -0
  30. package/dist/components/form/index.d.ts.map +1 -1
  31. package/dist/components/form/index.js +2 -0
  32. package/dist/components/form/index.js.map +1 -1
  33. package/package.json +3 -3
  34. package/src/components/form/engine/CompactRow.tsx +139 -0
  35. package/src/components/form/engine/FormEngine.tsx +229 -70
  36. package/src/components/form/engine/FormEngineRemote.stories.tsx +329 -0
  37. package/src/components/form/engine/compactRowContext.ts +8 -0
  38. package/src/components/form/engine/compactRowStyles.ts +30 -0
  39. package/src/components/form/engine/optionActions.ts +40 -0
  40. package/src/components/form/engine/readFirst.ts +25 -6
  41. package/src/components/form/engine/rendererTypes.ts +55 -0
  42. package/src/components/form/fields/auto/AutoFormField.tsx +6 -1
  43. package/src/components/form/index.tsx +2 -0
@@ -11,6 +11,7 @@
11
11
  import { StoryObj } from '@storybook/react-vite';
12
12
  import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
13
13
  import { useState } from 'react';
14
+ import { _testsClickButton, sleep } from '../../../stories/Tests/utils';
14
15
  import { StoryMeta } from '../../../types';
15
16
  import { FormEngine } from './FormEngine';
16
17
 
@@ -307,6 +308,334 @@ export const InjectedOptionActions: Story = {
307
308
  },
308
309
  };
309
310
 
311
+ /**
312
+ * The same injected actions seam must also work in compact/read-first mode. The
313
+ * IDE uses this path for option-based forms and relies on the action slot for
314
+ * small per-field Qonsole controls.
315
+ */
316
+ export const InjectedCompactOptionActions: Story = {
317
+ args: {
318
+ name: 'compactOptionActionsSeam',
319
+ compact: true,
320
+ options: BASIC_SCHEMA as any,
321
+ optionActions: ({ name }) => [
322
+ {
323
+ icon: 'MagicLine',
324
+ className: 'option-compact-ai-assist',
325
+ tooltip: `AI assistance for ${name}`,
326
+ // The shape the IDE passes on the classic path — hover-gated here too.
327
+ show: 'hover',
328
+ size: 'tiny',
329
+ fixed: true,
330
+ },
331
+ {
332
+ icon: 'InformationLine',
333
+ className: 'option-compact-always',
334
+ tooltip: `Details for ${name}`,
335
+ size: 'tiny',
336
+ fixed: true,
337
+ },
338
+ ],
339
+ },
340
+ parameters: {
341
+ docs: {
342
+ description: {
343
+ story:
344
+ 'Renders compact FormEngine rows with two injected per-option actions: one always visible, one declared `show: "hover"` that stays transparent until its row is hovered or focused. Hover-gating applies only where the pointer can hover — on touch both collapse into the row\'s overflow menu instead, so neither is ever hover-only.',
345
+ },
346
+ },
347
+ },
348
+ async play() {
349
+ await waitFor(
350
+ () => expect(document.querySelectorAll('.readfirst-row').length).toBeGreaterThan(0),
351
+ { timeout: 10000 }
352
+ );
353
+
354
+ const rowCount = document.querySelectorAll('.readfirst-row').length;
355
+
356
+ await waitFor(() => {
357
+ expect(document.querySelectorAll('.option-compact-always').length).toBe(rowCount);
358
+ expect(document.querySelectorAll('.option-compact-ai-assist').length).toBe(rowCount);
359
+ });
360
+
361
+ // The hover-only action carries the CSS gate; the always-on one does not.
362
+ const hoverAction = document.querySelector(
363
+ '.option-compact-ai-assist.options-injected-action-hover'
364
+ ) as HTMLElement;
365
+ expect(hoverAction).toBeTruthy();
366
+ expect(
367
+ document.querySelectorAll('.option-compact-always.options-injected-action-hover').length
368
+ ).toBe(0);
369
+
370
+ // Actually exercise the reveal condition rather than just asserting the
371
+ // class is present: gated the action is transparent and not hit-testable.
372
+ expect(getComputedStyle(hoverAction).opacity).toBe('0');
373
+ expect(getComputedStyle(hoverAction).pointerEvents).toBe('none');
374
+
375
+ // Focus is the reveal path a test can drive: the gate keys on
376
+ // `:hover, :focus-within`, and a synthetic mouse event does NOT put a real
377
+ // browser into `:hover`. Focusing the action is also the keyboard route a
378
+ // user takes, so this covers the accessibility path at the same time.
379
+ hoverAction.focus();
380
+ await waitFor(() => {
381
+ expect(getComputedStyle(hoverAction).opacity).toBe('1');
382
+ expect(getComputedStyle(hoverAction).pointerEvents).toBe('auto');
383
+ });
384
+
385
+ // Left revealed so the captured frame shows the action rather than an
386
+ // empty slot — the whole point of the story.
387
+ await sleep(200);
388
+ },
389
+ };
390
+
391
+ /**
392
+ * The no-hover case. A touch device never fires `:hover`, so a hover-gated
393
+ * action would be permanently unreachable; instead every injected action moves
394
+ * into the row's own overflow menu. `optionActionsCollapse='always'` forces the
395
+ * branch that `(hover: none)`/`(pointer: coarse)` picks on a real phone, which a
396
+ * desktop browser cannot emulate.
397
+ */
398
+ export const InjectedCompactOptionActionsMobile: Story = {
399
+ args: {
400
+ name: 'compactOptionActionsMobile',
401
+ compact: true,
402
+ options: BASIC_SCHEMA as any,
403
+ optionActionsCollapse: 'always',
404
+ optionActions: ({ name }) => [
405
+ {
406
+ icon: 'MagicLine',
407
+ className: 'option-compact-ai-assist',
408
+ tooltip: `AI assistance for ${name}`,
409
+ show: 'hover',
410
+ size: 'tiny',
411
+ fixed: true,
412
+ },
413
+ {
414
+ icon: 'InformationLine',
415
+ className: 'option-compact-always',
416
+ tooltip: `Details for ${name}`,
417
+ size: 'tiny',
418
+ fixed: true,
419
+ },
420
+ ],
421
+ },
422
+ parameters: {
423
+ qlip: { viewport: { width: 420, height: 900 } },
424
+ docs: {
425
+ description: {
426
+ story:
427
+ 'Renders the compact rows at phone width with injected actions collapsed into each row\'s overflow menu — the no-hover path, where a hover-gated button would otherwise be unreachable. The menu is opened so the actions are visible.',
428
+ },
429
+ },
430
+ },
431
+ async play() {
432
+ await waitFor(
433
+ () => expect(document.querySelectorAll('.readfirst-row').length).toBeGreaterThan(0),
434
+ { timeout: 10000 }
435
+ );
436
+
437
+ // Collapsed: no inline injected buttons at all, one overflow menu per row.
438
+ const rowCount = document.querySelectorAll('.readfirst-row').length;
439
+ await waitFor(() => {
440
+ expect(document.querySelectorAll('.options-injected-actions-menu').length).toBe(rowCount);
441
+ });
442
+ expect(document.querySelectorAll('.option-compact-ai-assist').length).toBe(0);
443
+ expect(document.querySelectorAll('.option-compact-always').length).toBe(0);
444
+
445
+ // Both actions are reachable from the menu — the reason the collapse exists.
446
+ await _testsClickButton({ selector: '.options-injected-actions-menu', nth: 0 });
447
+ await waitFor(
448
+ () => {
449
+ const labels = Array.from(document.querySelectorAll('.reqore-menu-item')).map(
450
+ (item) => item.textContent ?? ''
451
+ );
452
+ expect(labels.some((label) => label.includes('AI assistance for'))).toBe(true);
453
+ expect(labels.some((label) => label.includes('Details for'))).toBe(true);
454
+ },
455
+ { timeout: 10000 }
456
+ );
457
+ },
458
+ };
459
+
460
+ /**
461
+ * Many injected actions. Beyond the inline cap the extras overflow into the
462
+ * row's menu instead of squeezing the value out of the row, so a consumer can
463
+ * inject any number without breaking the layout.
464
+ */
465
+ export const InjectedCompactOptionActionsMany: Story = {
466
+ args: {
467
+ name: 'compactOptionActionsMany',
468
+ compact: true,
469
+ options: BASIC_SCHEMA as any,
470
+ optionActions: ({ name }) =>
471
+ ['MagicLine', 'InformationLine', 'FileCopyLine', 'DeleteBinLine', 'ShareLine'].map(
472
+ (icon, index) => ({
473
+ icon: icon as any,
474
+ className: `option-compact-many-${index}`,
475
+ tooltip: `${icon} for ${name}`,
476
+ size: 'tiny',
477
+ fixed: true,
478
+ })
479
+ ),
480
+ },
481
+ parameters: {
482
+ docs: {
483
+ description: {
484
+ story:
485
+ 'Renders the compact rows with five injected actions per option — the first two stay inline and the remaining three collapse into the row\'s overflow menu, which is opened here to show them.',
486
+ },
487
+ },
488
+ },
489
+ async play() {
490
+ await waitFor(
491
+ () => expect(document.querySelectorAll('.readfirst-row').length).toBeGreaterThan(0),
492
+ { timeout: 10000 }
493
+ );
494
+
495
+ const rowCount = document.querySelectorAll('.readfirst-row').length;
496
+
497
+ // Only the first two render inline; the rest are in the menu.
498
+ await waitFor(() => {
499
+ expect(document.querySelectorAll('.option-compact-many-0').length).toBe(rowCount);
500
+ expect(document.querySelectorAll('.option-compact-many-1').length).toBe(rowCount);
501
+ });
502
+ expect(document.querySelectorAll('.option-compact-many-2').length).toBe(0);
503
+ expect(document.querySelectorAll('.option-compact-many-4').length).toBe(0);
504
+ expect(document.querySelectorAll('.options-injected-actions-menu').length).toBe(rowCount);
505
+
506
+ await _testsClickButton({ selector: '.options-injected-actions-menu', nth: 0 });
507
+ await waitFor(
508
+ () => {
509
+ const labels = Array.from(document.querySelectorAll('.reqore-menu-item')).map(
510
+ (item) => item.textContent ?? ''
511
+ );
512
+ expect(labels.some((label) => label.includes('DeleteBinLine for'))).toBe(true);
513
+ expect(labels.some((label) => label.includes('ShareLine for'))).toBe(true);
514
+ },
515
+ { timeout: 10000 }
516
+ );
517
+ },
518
+ };
519
+
520
+ // A compact row renders the SAME logical state three ways — the read row, the
521
+ // inline editor, and the edit card (complex types get the card). An injected
522
+ // action has to survive all three, and per the affordance-parity rule each
523
+ // branch needs its own story: the branch without one is where a dead action
524
+ // hides, because the branch under test is the branch that works.
525
+ const BRANCH_SCHEMA = {
526
+ host: {
527
+ type: 'string',
528
+ display_name: 'Host',
529
+ short_desc: 'Edits inline inside the row',
530
+ preselected: true,
531
+ default_value: 'localhost',
532
+ },
533
+ notes: {
534
+ type: 'string',
535
+ ui_type: 'long-string',
536
+ display_name: 'Notes',
537
+ short_desc: 'A complex type — edits in the card branch',
538
+ preselected: true,
539
+ // Needs a value: an empty optional field sits in the collapsed "Optional"
540
+ // group, where `initialExpandedOptions` has nothing to open.
541
+ default_value: 'Notes that are long enough to want the card editor.',
542
+ },
543
+ };
544
+
545
+ const branchAction = ({ name }: { name: string }) => [
546
+ {
547
+ icon: 'MagicLine' as const,
548
+ className: 'option-branch-action',
549
+ tooltip: `AI assistance for ${name}`,
550
+ size: 'tiny' as const,
551
+ fixed: true,
552
+ },
553
+ ];
554
+
555
+ /**
556
+ * Branch 1 of 3: the INLINE editor. A simple type opens inside the row itself,
557
+ * and the injected action has to be reachable there, not just on the read row.
558
+ */
559
+ export const InjectedOptionActionsInlineEditing: Story = {
560
+ args: {
561
+ name: 'compactOptionActionsInline',
562
+ compact: true,
563
+ options: BRANCH_SCHEMA as any,
564
+ initialExpandedOptions: ['host'],
565
+ optionActions: branchAction,
566
+ },
567
+ parameters: {
568
+ docs: {
569
+ description: {
570
+ story:
571
+ 'Renders the compact form with the simple `host` field already open, so it edits inline inside the row — the injected per-option action is asserted inside that inline branch.',
572
+ },
573
+ },
574
+ },
575
+ async play() {
576
+ await waitFor(
577
+ () => expect(document.querySelector('.readfirst-row-editing')).toBeTruthy(),
578
+ { timeout: 10000 }
579
+ );
580
+
581
+ // Scoped to the branch on purpose: a global query would pass on the read
582
+ // row's copy and prove nothing about the inline editor.
583
+ const injected = await waitFor(() => {
584
+ const el = document.querySelector(
585
+ '.readfirst-row-editing .option-branch-action'
586
+ ) as HTMLElement;
587
+ expect(el).toBeTruthy();
588
+ return el;
589
+ });
590
+
591
+ // The injected action must match the size of the row's own actions. The
592
+ // fixture deliberately asks for `size: 'tiny'` — the row overrides it,
593
+ // because a consumer-sized button rendered visibly smaller than the revert
594
+ // and More buttons beside it (qlip build #57 rejection).
595
+ const neighbour = document.querySelector(
596
+ '.readfirst-row-editing .options-readfirst-more'
597
+ ) as HTMLElement;
598
+ expect(neighbour).toBeTruthy();
599
+ expect(injected.getBoundingClientRect().height).toBe(
600
+ neighbour.getBoundingClientRect().height
601
+ );
602
+ },
603
+ };
604
+
605
+ /**
606
+ * Branch 2 of 3: the EDIT CARD. A complex type (`long-string`) is too tall to
607
+ * edit in-row, so it opens as a card — a different subtree, and historically
608
+ * where an action wired only in the other branch went missing.
609
+ */
610
+ export const InjectedOptionActionsEditCard: Story = {
611
+ args: {
612
+ name: 'compactOptionActionsCard',
613
+ compact: true,
614
+ options: BRANCH_SCHEMA as any,
615
+ initialExpandedOptions: ['notes'],
616
+ optionActions: branchAction,
617
+ },
618
+ parameters: {
619
+ docs: {
620
+ description: {
621
+ story:
622
+ 'Renders the compact form with the complex `notes` field already open, so it edits in the card branch rather than in-row — the injected per-option action is asserted inside that card.',
623
+ },
624
+ },
625
+ },
626
+ async play() {
627
+ await waitFor(() => expect(document.querySelector('.options-readfirst-card')).toBeTruthy(), {
628
+ timeout: 10000,
629
+ });
630
+
631
+ await waitFor(() => {
632
+ expect(
633
+ document.querySelector('.options-readfirst-card .option-branch-action')
634
+ ).toBeTruthy();
635
+ });
636
+ },
637
+ };
638
+
310
639
  /**
311
640
  * Opt-in template fetching: `interfaceContext` makes `useTemplates` fetch
312
641
  * `system/getContextData` (the IDE's behavior); without it, no request is
@@ -9,6 +9,7 @@ import {
9
9
  import { MutableRefObject } from 'react';
10
10
  import { createContext } from 'use-context-selector';
11
11
  import { IOperatorsSchema } from './FormEngine';
12
+ import { TOptionActions } from './optionActions';
12
13
 
13
14
  /**
14
15
  * The complete closure surface of the (former) `renderCompactRow` function,
@@ -74,6 +75,13 @@ export interface ICompactRowContext {
74
75
 
75
76
  // Function passed through as a value (stays defined in FormEngine because the
76
77
  // classic non-compact path uses it too).
78
+ optionActions?: TOptionActions;
79
+ /**
80
+ * Injected actions render inside the row's overflow menu instead of as inline
81
+ * buttons. True on touch (where a hover-gated button is unreachable) and on
82
+ * narrow viewports. Resolved once by FormEngine so rows share one subscription.
83
+ */
84
+ collapseOptionActions?: boolean;
77
85
  renderOption: (
78
86
  optionName: string,
79
87
  field: IQorusFormField,
@@ -73,6 +73,36 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
73
73
  flex: 1 1 auto;
74
74
  min-width: 0;
75
75
  }
76
+
77
+ /* An injected option action declared \`show: 'hover'\` — the shape ReqorePanel
78
+ honours on the classic path — is revealed only while its row or card is
79
+ hovered or holds focus. The compact slots render plain buttons, so the gate
80
+ has to be CSS here. Kept focus-visible too, so the action stays reachable by
81
+ keyboard. Opacity rather than display keeps the slot's width stable, so rows
82
+ don't reflow on hover. */
83
+ .options-injected-action-hover {
84
+ opacity: 0;
85
+ /* Not hit-testable while invisible — an opacity:0 button still takes taps
86
+ and clicks, which reads as a mystery control firing out of nowhere. */
87
+ pointer-events: none;
88
+ transition: opacity 0.15s ease;
89
+ }
90
+ .readfirst-row:hover .options-injected-action-hover,
91
+ .readfirst-row:focus-within .options-injected-action-hover,
92
+ .options-readfirst-card:hover .options-injected-action-hover,
93
+ .options-readfirst-card:focus-within .options-injected-action-hover {
94
+ opacity: 1;
95
+ pointer-events: auto;
96
+ }
97
+ /* Touch and other hover-less pointers never fire :hover, so a hover-gated
98
+ action would be permanently unreachable there. Show it unconditionally
99
+ instead — losing the tidiness beats losing the functionality. */
100
+ @media (hover: none), (pointer: coarse) {
101
+ .options-injected-action-hover {
102
+ opacity: 1;
103
+ pointer-events: auto;
104
+ }
105
+ }
76
106
  `;
77
107
 
78
108
  // Compact group header laid out as the panel's `label`: the group name, a
@@ -0,0 +1,40 @@
1
+ import { IReqorePanelAction } from '@qoretechnologies/reqore/dist/components/Panel';
2
+ import { IQorusFormField, IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
3
+
4
+ /**
5
+ * The context an `optionActions` factory receives for a single option — the
6
+ * option's name, its schema entry, and its current field value. This is the
7
+ * IDE's `AiAssistanceAction` context (it injects its `allowAi` button here).
8
+ */
9
+ export interface IOptionActionsContext {
10
+ name: string;
11
+ schema: IQorusFormSchema[string];
12
+ value?: IQorusFormField;
13
+ }
14
+
15
+ /**
16
+ * SEAM (reqraft): per-option injected actions. Either a static list applied to
17
+ * every option, or a factory invoked once per option with that option's context.
18
+ */
19
+ export type TOptionActions =
20
+ | IReqorePanelAction[]
21
+ | ((context: IOptionActionsContext) => IReqorePanelAction[]);
22
+
23
+ /**
24
+ * Resolve the `optionActions` seam for one option.
25
+ *
26
+ * Both render paths go through this so a consumer's factory is called with the
27
+ * same context and its result shaped the same way: the classic path feeds the
28
+ * result to `ReqorePanel`'s `actions`, the compact path renders the buttons
29
+ * itself. Falsy entries are dropped so a factory can return conditional actions
30
+ * inline (`cond && {...}`) without each caller re-filtering.
31
+ */
32
+ export const resolveOptionActions = (
33
+ optionActions: TOptionActions | undefined,
34
+ context: IOptionActionsContext
35
+ ): IReqorePanelAction[] => {
36
+ const actions =
37
+ typeof optionActions === 'function' ? optionActions(context) : (optionActions ?? []);
38
+
39
+ return (actions ?? []).filter((action): action is IReqorePanelAction => !!action);
40
+ };
@@ -1,4 +1,9 @@
1
- import { IQorusFormField, TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit';
1
+ import {
2
+ IQorusFormField,
3
+ TQorusFormFieldSchema,
4
+ TQorusType,
5
+ } from '@qoretechnologies/ts-toolkit';
6
+ import { isRendererOnlyUiType } from './rendererTypes';
2
7
  import { isUiEncodedValue } from './_structuredData/structuredData';
3
8
  import { renderExpressionToText } from '../expressions/renderExpressionToText';
4
9
  import { IExpressionValue } from '../expressions/types';
@@ -223,15 +228,29 @@ export const getFileSize = (value: unknown): number | undefined => {
223
228
  return typeof size === 'number' && Number.isFinite(size) && size >= 0 ? size : undefined;
224
229
  };
225
230
 
226
- /** Effective UI type for display: the stored `type` wins (any/auto picks),
227
- * then schema `ui_type`, then `type`. */
231
+ /**
232
+ * Effective UI type for DISPLAY — which preview/summary a value renders as.
233
+ *
234
+ * A renderer-only `ui_type` wins outright: the field's stored `type` holds the
235
+ * STORAGE type for those (a `code-editor` stores a string), so consulting the
236
+ * stored type here would render the bespoke editor's value as a plain string and
237
+ * silently drop its preview. Every other case keeps the stored type first, so an
238
+ * `any`/`auto` field still displays as whatever concrete type the user picked.
239
+ */
228
240
  export const getValueType = (
229
241
  option?: IQorusFormField,
230
242
  schema?: TQorusFormFieldSchema
231
- ): string | undefined =>
232
- (option?.type ||
233
- (schema as { ui_type?: string; type?: string } | undefined)?.ui_type ||
243
+ ): string | undefined => {
244
+ const uiType = (schema as { ui_type?: string } | undefined)?.ui_type;
245
+
246
+ if (isRendererOnlyUiType(uiType as TQorusType)) {
247
+ return uiType;
248
+ }
249
+
250
+ return (option?.type ||
251
+ uiType ||
234
252
  (schema as { type?: string } | undefined)?.type) as string | undefined;
253
+ };
235
254
 
236
255
  /** Human-readable byte count (e.g. `1.2 KB`). */
237
256
  export const formatBytes = (bytes: number): string => {
@@ -0,0 +1,55 @@
1
+ import { TQorusType } from '@qoretechnologies/ts-toolkit';
2
+
3
+ /**
4
+ * A schema entry carries two type-ish keys with different jobs:
5
+ *
6
+ * - `type` names how the value is STORED and validated (`string`, `hash`, `list`…).
7
+ * - `ui_type` names which EDITOR renders it.
8
+ *
9
+ * For most fields `ui_type` is a storage-compatible refinement (`long-string`,
10
+ * `enum`, `url`) and it is safe to let it win for both jobs. But some `ui_type`s
11
+ * name a bespoke editor whose value is still stored as the plainer `type` — a
12
+ * `cron` editor stores a string, `processor-mappings` stores a hash. Writing
13
+ * those renderer names into the field's `type` breaks validation and round-trips
14
+ * the value through the wrong branch, which is what `RENDERER_ONLY_UI_TYPES`
15
+ * exists to prevent.
16
+ *
17
+ * There is deliberately no derivation here: a renderer-only `ui_type` is not
18
+ * distinguishable from a storage-compatible one by inspecting the value or the
19
+ * `TQorusType` union (`code-editor` and `tool-catalog` are members of it, and
20
+ * the validator has cases for `cron` and `schema-definition`). It is a fact
21
+ * about which editor a name selects, so it has to be declared.
22
+ */
23
+ export const BUILT_IN_RENDERER_ONLY_UI_TYPES: readonly string[] = [
24
+ 'active-windows',
25
+ 'alert-threshold',
26
+ 'code-editor',
27
+ 'cron',
28
+ 'dpql',
29
+ 'processor-mappings',
30
+ 'schema-definition',
31
+ 'test-cases',
32
+ 'test-value-contract',
33
+ 'tool-catalog',
34
+ ];
35
+
36
+ /**
37
+ * Build the renderer-only predicate for one FormEngine instance.
38
+ *
39
+ * Consumers inject their own editors through `componentOverrides`, so the set of
40
+ * renderer-only names is open-ended — the IDE ships editors reqraft has never
41
+ * heard of. `extraTypes` (the `rendererOnlyUiTypes` prop) lets a consumer declare
42
+ * its own without waiting on a reqraft release, which is what previously made
43
+ * every new IDE editor a silent value-corruption bug until this list caught up.
44
+ */
45
+ export const createRendererOnlyUiTypeCheck = (
46
+ extraTypes?: readonly string[]
47
+ ): ((type?: TQorusType | TQorusType[]) => boolean) => {
48
+ const known = new Set<string>([...BUILT_IN_RENDERER_ONLY_UI_TYPES, ...(extraTypes ?? [])]);
49
+
50
+ return (type?: TQorusType | TQorusType[]): boolean =>
51
+ typeof type === 'string' && known.has(type);
52
+ };
53
+
54
+ /** The default predicate — built-ins only, for module-scope callers with no props. */
55
+ export const isRendererOnlyUiType = createRendererOnlyUiTypeCheck();
@@ -356,7 +356,12 @@ function AutoField<T = any>({
356
356
  {...rest}
357
357
  name={name}
358
358
  value={value}
359
- onChange={(val: any) => handleChange(name, val)}
359
+ onChange={(val: any, emittedType?: IQorusType, emittedIsFunction?: boolean) => {
360
+ const returnType = emittedType || currentType;
361
+ if (onChange && returnType) {
362
+ onChange(name, val, returnType, emittedIsFunction);
363
+ }
364
+ }}
360
365
  />
361
366
  );
362
367
  }
@@ -22,6 +22,8 @@ export * from './fields/select/SelectCollection';
22
22
  export * from './fields/string/String';
23
23
  export * from './fields/template/TemplateField';
24
24
  export * from './engine/FormEngine';
25
+ export * from './engine/optionActions';
26
+ export * from './engine/rendererTypes';
25
27
  export * from './expressions/types';
26
28
  export * from './expressions/useExpressions';
27
29
  // Named (not wildcard) so the `_resetRenderExpressionTransportForTests`