@qoretechnologies/reqraft 0.10.39-pr.99.gdbafa34 → 0.10.39

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 (110) hide show
  1. package/.claude/CLAUDE.md +12 -0
  2. package/dist/components/codeSize/index.d.ts +37 -0
  3. package/dist/components/codeSize/index.d.ts.map +1 -0
  4. package/dist/components/codeSize/index.js +87 -0
  5. package/dist/components/codeSize/index.js.map +1 -0
  6. package/dist/components/form/engine/CompactRow.d.ts +20 -1
  7. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  8. package/dist/components/form/engine/CompactRow.js +365 -30
  9. package/dist/components/form/engine/CompactRow.js.map +1 -1
  10. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  11. package/dist/components/form/engine/CompactToolbar.js +6 -8
  12. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  13. package/dist/components/form/engine/FormEngine.d.ts +24 -45
  14. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  15. package/dist/components/form/engine/FormEngine.js +211 -84
  16. package/dist/components/form/engine/FormEngine.js.map +1 -1
  17. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts +37 -0
  18. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -0
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.js +335 -0
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -0
  21. package/dist/components/form/engine/compactRowStyles.d.ts +13 -0
  22. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  23. package/dist/components/form/engine/compactRowStyles.js +30 -5
  24. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  25. package/dist/components/form/engine/compactToolbarContext.d.ts +0 -8
  26. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  28. package/dist/components/form/engine/readFirst.d.ts +73 -1
  29. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  30. package/dist/components/form/engine/readFirst.js +175 -5
  31. package/dist/components/form/engine/readFirst.js.map +1 -1
  32. package/dist/components/form/fields/Field.d.ts.map +1 -1
  33. package/dist/components/form/fields/Field.js +1 -1
  34. package/dist/components/form/fields/Field.js.map +1 -1
  35. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  36. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  37. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  38. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  39. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  40. package/dist/components/form/fields/array/ArrayAuto.js +51 -21
  41. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  42. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  43. package/dist/components/form/fields/array/ArrayAutoField.js +42 -25
  44. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  45. package/dist/components/form/fields/auto/AutoFormField.d.ts +4 -0
  46. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  47. package/dist/components/form/fields/auto/AutoFormField.js +29 -4
  48. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  49. package/dist/components/form/fields/long-string/LongString.d.ts +9 -1
  50. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  51. package/dist/components/form/fields/long-string/LongString.js +25 -4
  52. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  53. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  54. package/dist/components/form/fields/template/TemplateField.js +12 -2
  55. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  56. package/dist/helpers/common.d.ts +8 -1
  57. package/dist/helpers/common.d.ts.map +1 -1
  58. package/dist/helpers/common.js +9 -1
  59. package/dist/helpers/common.js.map +1 -1
  60. package/dist/helpers/optionUiTypes.d.ts +30 -0
  61. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  62. package/dist/helpers/optionUiTypes.js +148 -0
  63. package/dist/helpers/optionUiTypes.js.map +1 -0
  64. package/dist/helpers/options.d.ts +19 -0
  65. package/dist/helpers/options.d.ts.map +1 -1
  66. package/dist/helpers/options.js +25 -1
  67. package/dist/helpers/options.js.map +1 -1
  68. package/dist/helpers/providerValue.d.ts +68 -0
  69. package/dist/helpers/providerValue.d.ts.map +1 -0
  70. package/dist/helpers/providerValue.js +50 -0
  71. package/dist/helpers/providerValue.js.map +1 -0
  72. package/dist/helpers/singleLineString.d.ts +37 -0
  73. package/dist/helpers/singleLineString.d.ts.map +1 -0
  74. package/dist/helpers/singleLineString.js +66 -0
  75. package/dist/helpers/singleLineString.js.map +1 -0
  76. package/dist/helpers/validations.d.ts +85 -2
  77. package/dist/helpers/validations.d.ts.map +1 -1
  78. package/dist/helpers/validations.js +802 -76
  79. package/dist/helpers/validations.js.map +1 -1
  80. package/dist/index.d.ts +1 -0
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +1 -0
  83. package/dist/index.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/components/codeSize/index.tsx +79 -0
  86. package/src/components/form/engine/CompactRow.tsx +450 -31
  87. package/src/components/form/engine/CompactToolbar.tsx +16 -21
  88. package/src/components/form/engine/FormEngine.stories.tsx +804 -17
  89. package/src/components/form/engine/FormEngine.tsx +207 -105
  90. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +233 -0
  91. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +630 -0
  92. package/src/components/form/engine/compactRowStyles.ts +44 -2
  93. package/src/components/form/engine/compactToolbarContext.ts +0 -8
  94. package/src/components/form/engine/readFirst.ts +201 -5
  95. package/src/components/form/fields/Field.tsx +3 -1
  96. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  97. package/src/components/form/fields/array/ArrayAuto.tsx +35 -4
  98. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +134 -0
  99. package/src/components/form/fields/array/ArrayAutoField.tsx +22 -4
  100. package/src/components/form/fields/auto/AutoFormField.tsx +30 -1
  101. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  102. package/src/components/form/fields/template/TemplateField.tsx +16 -1
  103. package/src/helpers/common.ts +11 -1
  104. package/src/helpers/optionUiTypes.ts +153 -0
  105. package/src/helpers/options.ts +23 -0
  106. package/src/helpers/providerValue.ts +125 -0
  107. package/src/helpers/singleLineString.ts +61 -0
  108. package/src/helpers/validations.ts +1105 -80
  109. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  110. package/src/index.tsx +1 -0
@@ -49,7 +49,12 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
49
49
  import { useMeasure, useMount, useUpdateEffect } from 'react-use';
50
50
  import styled from 'styled-components';
51
51
  import { createContext } from 'use-context-selector';
52
- import { getDefaultValue, insertAtIndex, richtextToString } from '../../../helpers/common';
52
+ import {
53
+ fixOperatorValue,
54
+ getDefaultValue,
55
+ insertAtIndex,
56
+ richtextToString,
57
+ } from '../../../helpers/common';
53
58
  import { getRequiredOptionMessage } from '../../../helpers/options';
54
59
  import {
55
60
  IValidationResult,
@@ -107,6 +112,7 @@ import {
107
112
  import { OptionsHelpDialog } from './OptionsHelpDialog';
108
113
  import {
109
114
  TReadFirstStatus,
115
+ findAllowedValueOption,
110
116
  getFirstAttentionOptionName,
111
117
  getOptionGroup,
112
118
  getOptionGroupLabel,
@@ -119,21 +125,6 @@ import {
119
125
  } from './readFirst';
120
126
 
121
127
  // Re-export types for consumers
122
- /** The compact form's three status boxes. */
123
- export type TFormEngineBoxKey = 'attention' | 'set' | 'optional';
124
-
125
- /** Which parts of the compact toolbar to render — see `compactToolbar`. */
126
- export interface IFormEngineToolbarParts {
127
- /** The "Ready / 4 of 6 set / 67%" line and its meter. */
128
- completion?: boolean;
129
- /** The "Filter fields…" input. */
130
- search?: boolean;
131
- /** The field-picker dropdown (add optional fields, reset, revert). */
132
- fields?: boolean;
133
- /** The help button that opens the options dialog. */
134
- help?: boolean;
135
- }
136
-
137
128
  export type IOptions = TQorusForm;
138
129
  export type TFlatOptions = TQorusFlatForm;
139
130
  export type TOption = IQorusFormField;
@@ -306,9 +297,10 @@ const getTypeFromOperator = (
306
297
  return (operators[last(operatorData) as string]?.type as TQorusType) || null;
307
298
  };
308
299
 
309
- export const fixOperatorValue = (operator: TOperatorValue): (string | null | undefined)[] => {
310
- return isArray(operator) ? operator : [operator];
311
- };
300
+ /** Re-exported from `helpers/common` so existing importers keep working; it
301
+ * lives there because `helpers/validations` needs it and cannot import this
302
+ * module (this module imports the validators). */
303
+ export { fixOperatorValue } from '../../../helpers/common';
312
304
 
313
305
  /**
314
306
  * The renderer-only predicate. Defaults to reqraft's built-ins; a FormEngine
@@ -387,6 +379,23 @@ export const fixOptions = (
387
379
  (fixedValue[name] as IQorusFormField)?.value === undefined
388
380
  ) {
389
381
  obj = option.default_value as IQorusFormField;
382
+ } else if (
383
+ option.default_view === 'expression' &&
384
+ (fixedValue[name] as IQorusFormField)?.value === undefined
385
+ ) {
386
+ // A field the schema declares as opening in expression mode starts as an
387
+ // EMPTY expression, not as its raw default. `isDefaultFunction` already
388
+ // makes the renderer open that way, so without this the data disagreed
389
+ // with what the operator was looking at: an expression editor whose
390
+ // value was still the plain default, and no `is_expression` on the field
391
+ // until the first edit.
392
+ obj = {
393
+ type,
394
+ value: {
395
+ args: [],
396
+ },
397
+ is_expression: true,
398
+ };
390
399
  } else {
391
400
  obj = {
392
401
  type,
@@ -444,13 +453,28 @@ export const fixOptions = (
444
453
  };
445
454
  }
446
455
 
456
+ // A value that is not one of the declared choices is dropped. What counts
457
+ // as "one of the choices" is `findAllowedValueOption` — the same predicate
458
+ // the read-first row uses to LABEL a value — because a value the row can
459
+ // name is by definition a value the form must keep. Inlining a narrower
460
+ // test here (envelope and `name`, but not a bare `value`) silently erased
461
+ // every value declared the bare way: the collapsed row still showed its
462
+ // display name while the editor showed "—" and the value never reached
463
+ // the submitted data.
464
+ // `allowed_values: []` is NOT "no value is permitted" — it is the server
465
+ // saying it has no reference values to offer right now (the app-action
466
+ // catalogue ships exactly that, with an
467
+ // `option_reference_values_unavailable` message attached). An empty array
468
+ // is truthy, so testing the array itself made every such option erase the
469
+ // operator's stored value on the first render, before the
470
+ // connection-refreshed schema arrived to say otherwise — and the emptied
471
+ // form then autosaved over the draft, so the value was gone for good.
472
+ // Only an option that actually declares choices can have a value that is
473
+ // not one of them.
447
474
  if (
448
475
  newOption.value !== undefined &&
449
- options?.[optionName]?.allowed_values &&
450
- !options?.[optionName]?.allowed_values?.find(
451
- (allowedValue: any) =>
452
- allowedValue.value?.value === newOption.value || allowedValue.name === newOption.value
453
- ) &&
476
+ options?.[optionName]?.allowed_values?.length &&
477
+ !findAllowedValueOption(newOption.value, options?.[optionName]) &&
454
478
  !isValueTemplate(newOption.value) &&
455
479
  !options?.[optionName]?.multiselect &&
456
480
  !options?.[optionName]?.allowed_values_creatable
@@ -635,37 +659,6 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
635
659
  * No-op in classic (non-compact) mode.
636
660
  */
637
661
  compactPanelProps?: IReqorePanelProps;
638
- /**
639
- * Compact mode only: which status boxes start collapsed. Defaults to
640
- * `['optional']` — a form opens on what is in use.
641
- *
642
- * Pass `['set', 'optional']` where the form is one item in a longer list and
643
- * the reader is choosing WHICH to open rather than reading this one, e.g. a
644
- * template's per-action option forms stacked in a drawer.
645
- *
646
- * Two escapes override this and are not negotiable: a box always opens while
647
- * a search query is running (`ReqorePanel` unmounts collapsed content, so a
648
- * match inside a closed box would be unreachable), and the Optional box stays
649
- * open when every row is optional, because collapsing it would render a card
650
- * that looks empty and broken.
651
- */
652
- compactCollapsedGroups?: TFormEngineBoxKey[];
653
- /**
654
- * Compact mode only: which parts of the form's toolbar to show. `true`
655
- * (default) shows all of it; `false` hides the whole thing.
656
- *
657
- * The parts already hide themselves when they have nothing to say — the
658
- * completion meter needs at least one counted option, and the search and
659
- * field-picker need more than one option to pick between — so reach for this
660
- * only to hide something that WOULD have rendered. A per-action form asking
661
- * for one value, say, has a meter reading 0/1 that tells the reader nothing
662
- * they cannot see in the row below it.
663
- *
664
- * Hiding `search` removes the only way to run a query, which is what forces
665
- * a collapsed box open; `compactCollapsedGroups` then fully determines what
666
- * is open.
667
- */
668
- compactToolbar?: boolean | IFormEngineToolbarParts;
669
662
  /** Compact mode only: per-group display metadata (label / icon / subtitle /
670
663
  * order) — the server only sends the bare group key. */
671
664
  groups?: Record<string, IFormEngineGroup>;
@@ -761,6 +754,27 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
761
754
  */
762
755
  autoFocusFirstRequired?: boolean;
763
756
 
757
+ /**
758
+ * Opt-in: on mount, OPEN the first row that needs attention — without taking
759
+ * focus.
760
+ *
761
+ * `autoFocusFirstRequired` fuses two decisions that are not the same: which
762
+ * row is open (layout) and where the caret is (focus). Focus is the half that
763
+ * can be stolen, so that flag waits for focus to be free — and a form mounted
764
+ * BY a click never sees free focus, because the button that mounted it still
765
+ * has it. Adding a list item is exactly that case: the row the author must
766
+ * fill stayed shut behind a second click, and the flag that exists to prevent
767
+ * it could not fire.
768
+ *
769
+ * Opening a row takes nothing from the user, so this half needs no guard. Use
770
+ * it where the mount is already the answer to a deliberate action; use
771
+ * `autoFocusFirstRequired` where the form is the destination and the caret
772
+ * should land in it. Setting both keeps the focusing behaviour. Same one-shot
773
+ * contract: it fires on the first render with focusable content and then never
774
+ * again for the life of the instance. No-op in classic mode. Default: off.
775
+ */
776
+ expandFirstRequired?: boolean;
777
+
764
778
  /**
765
779
  * Names of read-first rows to open on mount, in addition to whatever the
766
780
  * user opens afterwards.
@@ -811,8 +825,6 @@ const FormEngineImpl = ({
811
825
  compactFlush = false,
812
826
  compactNested = false,
813
827
  compactPanelProps,
814
- compactCollapsedGroups = ['optional'],
815
- compactToolbar = true,
816
828
  commitMode = 'immediate',
817
829
  expandMode = 'single',
818
830
  onCommit,
@@ -831,6 +843,7 @@ const FormEngineImpl = ({
831
843
  rendererOnlyUiTypes,
832
844
  inheritedFromParent,
833
845
  autoFocusFirstRequired,
846
+ expandFirstRequired,
834
847
  initialExpandedOptions,
835
848
  ...rest
836
849
  }: IFormEngineProps) => {
@@ -879,6 +892,47 @@ const FormEngineImpl = ({
879
892
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
880
893
  // Which options are expanded into their editor (several can be open at once).
881
894
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
895
+
896
+ // --- Reveal a status box's content when it is opened ----------------------
897
+ //
898
+ // The status boxes stack, so the last one ("Optional", which holds every
899
+ // not-yet-added field) sits at the bottom of the form — frequently at the
900
+ // bottom of the scroll container too. Opening it mounts its rows BELOW the
901
+ // fold: the box's own header is all that stays on screen, the click appears to
902
+ // have done nothing, and the fields it just revealed have to be hunted for by
903
+ // scrolling.
904
+ //
905
+ // The box that was just opened, read by the layout effect below. Held as state
906
+ // rather than acted on inside the collapse handler because the handler runs
907
+ // BEFORE React re-renders — at that moment the rows do not exist yet, so the
908
+ // box has not grown and there is nothing to scroll to. A layout effect runs
909
+ // after the DOM is updated and before paint, which makes the reveal
910
+ // deterministic instead of a guessed delay.
911
+ const [openedStatusBox, setOpenedStatusBox] = useState<string | undefined>(undefined);
912
+ const statusBoxRefs = useRef<Record<string, HTMLDivElement | null>>({});
913
+ useLayoutEffect(() => {
914
+ if (!openedStatusBox) {
915
+ return;
916
+ }
917
+ const element = statusBoxRefs.current[openedStatusBox];
918
+ // Consume it either way: a box that has gone (a search narrowed it out of
919
+ // existence) must not leave a pending reveal armed for a later render.
920
+ setOpenedStatusBox(undefined);
921
+ // `scrollIntoView` is not implemented in jsdom, and a missing reveal must
922
+ // never break a form.
923
+ if (!element?.scrollIntoView) {
924
+ return;
925
+ }
926
+ // 'nearest' scrolls the MINIMUM needed: nothing at all when the box is
927
+ // already fully visible, and a top-alignment when it is taller than the
928
+ // viewport (header plus the first rows — the right answer for a long
929
+ // Optional list). Anything else moves the page when it did not need to.
930
+ element.scrollIntoView({
931
+ block: 'nearest',
932
+ behavior:
933
+ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth',
934
+ });
935
+ }, [openedStatusBox]);
882
936
  // Remembers each row's last settled status box, so an actively-edited field
883
937
  // stays put when its status flips (e.g. becomes valid) instead of jumping to
884
938
  // another box mid-edit and stealing focus. Keyed by option name.
@@ -1911,7 +1965,11 @@ const FormEngineImpl = ({
1911
1965
  // (or below-the-fold) form is never scrolled into view on mount.
1912
1966
  const autoFocusNameRef = useRef<string | undefined>(undefined);
1913
1967
  useEffect(() => {
1914
- if (!autoFocusFirstRequired || !compact || !options) {
1968
+ // Two decisions, one scan. `autoFocusFirstRequired` opens the row AND puts
1969
+ // the caret in it; `expandFirstRequired` only opens it. Setting both keeps
1970
+ // the focusing behaviour, since focusing implies opening.
1971
+ const wantsFocus = !!autoFocusFirstRequired;
1972
+ if ((!wantsFocus && !expandFirstRequired) || !compact || !options) {
1915
1973
  return;
1916
1974
  }
1917
1975
  if (hasAutoFocusedRef.current) {
@@ -1929,8 +1987,14 @@ const FormEngineImpl = ({
1929
1987
  // clean mount focus rests on the body, so the intended "drop into the first
1930
1988
  // field" still fires. Bailing here leaves `hasAutoFocusedRef` false, so it
1931
1989
  // retries once focus is free.
1990
+ //
1991
+ // Only the FOCUSING variant waits: opening a row moves no caret, so there is
1992
+ // nothing to steal and nothing to wait for. Making the expand-only variant
1993
+ // wait here would make it a no-op in its main use — a sub-form mounted by a
1994
+ // click, where the button that mounted it still holds focus and never gives
1995
+ // it up.
1932
1996
  const active = document.activeElement as HTMLElement | null;
1933
- if (active && active !== document.body) {
1997
+ if (wantsFocus && active && active !== document.body) {
1934
1998
  return;
1935
1999
  }
1936
2000
 
@@ -1958,7 +2022,10 @@ const FormEngineImpl = ({
1958
2022
 
1959
2023
  if (target) {
1960
2024
  // Set the ref before the state update so CompactRow's focus timer sees it.
1961
- autoFocusNameRef.current = target;
2025
+ // Expand-only leaves it unset, which is what keeps the caret where it is.
2026
+ if (wantsFocus) {
2027
+ autoFocusNameRef.current = target;
2028
+ }
1962
2029
  setExpandedOptions((prev) =>
1963
2030
  prev.includes(target) ? prev
1964
2031
  : expandMode === 'multi' ? [...prev, target]
@@ -1967,6 +2034,7 @@ const FormEngineImpl = ({
1967
2034
  }
1968
2035
  }, [
1969
2036
  autoFocusFirstRequired,
2037
+ expandFirstRequired,
1970
2038
  compact,
1971
2039
  options,
1972
2040
  availableOptions,
@@ -2468,14 +2536,6 @@ const FormEngineImpl = ({
2468
2536
  ]
2469
2537
  );
2470
2538
 
2471
- // `compactToolbar` resolved to a full object, so neither the context type nor
2472
- // the toolbar itself has to re-default anything. `null` means no toolbar.
2473
- const compactToolbarParts = useMemo(() => {
2474
- if (compactToolbar === false) return null;
2475
- const all = { completion: true, search: true, fields: true, help: true };
2476
- return compactToolbar === true ? all : { ...all, ...compactToolbar };
2477
- }, [JSON.stringify(compactToolbar)]);
2478
-
2479
2539
  const compactToolbarContextValue = useMemo<ICompactToolbarContext>(
2480
2540
  () => ({
2481
2541
  readOnly,
@@ -2484,7 +2544,6 @@ const FormEngineImpl = ({
2484
2544
  completion: readFirstCompletion,
2485
2545
  showInvalidOnly: showInvalidOptionsOnly,
2486
2546
  onToggleInvalidOnly: handleToggleInvalidOnly,
2487
- parts: compactToolbarParts ?? { completion: false, search: false, fields: false, help: false },
2488
2547
  hasMultipleOptions: size(availableOptions) > 1,
2489
2548
  compactQuery,
2490
2549
  setCompactQuery,
@@ -2535,14 +2594,10 @@ const FormEngineImpl = ({
2535
2594
 
2536
2595
  // The engine's own toolbar action, plus whatever the consumer added through
2537
2596
  // `compactPanelProps.actions` — appended, so an outside action can never
2538
- // knock the form's toolbar out of the header. Only `compactToolbar={false}`
2539
- // removes it, and then the header carries just the consumer's own actions.
2597
+ // knock the form's toolbar out of the header.
2540
2598
  const compactHeaderActions = useMemo(
2541
- () => [
2542
- ...(compactToolbarParts ? [{ as: CompactToolbar, responsive: false }] : []),
2543
- ...(compactPanelProps?.actions || []),
2544
- ],
2545
- [compactPanelProps?.actions, compactToolbarParts]
2599
+ () => [{ as: CompactToolbar, responsive: false }, ...(compactPanelProps?.actions || [])],
2600
+ [compactPanelProps?.actions]
2546
2601
  );
2547
2602
  const renderCompact = () => {
2548
2603
  const headerBg = `${changeDarkness(getMainBackgroundColor(theme), 0.02)}${percentToHexAlpha(88)}`;
@@ -2555,9 +2610,40 @@ const FormEngineImpl = ({
2555
2610
  if (requiredOnly && !(schema?.required || schema?.required_groups)) {
2556
2611
  return false;
2557
2612
  }
2558
- return matchesQuery(optionName);
2613
+ // A field that absorbs a sibling also answers for it: searching
2614
+ // "language" has to surface the row that now holds the language control,
2615
+ // or the field becomes unreachable through the filter.
2616
+ if (matchesQuery(optionName)) return true;
2617
+ const absorb = (schema as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2618
+ return !!absorb?.some((absorbedName) => matchesQuery(absorbedName));
2559
2619
  };
2560
2620
 
2621
+ // Fields a sibling absorbs into its own container.
2622
+ //
2623
+ // A field declaring `absorb_fields: ['language']` renders that sibling's
2624
+ // control inside its own row instead of letting it have a row of its own.
2625
+ // The pair this exists for is a code editor and its language: they are one
2626
+ // decision — "what is this code, and in what language" — and reading them
2627
+ // as two unrelated rows one above the other is what the layout used to say.
2628
+ //
2629
+ // Absorbed fields stay in `grouped`, and therefore in the Set / Optional /
2630
+ // Needs-attention counts and in the completion meter. Only the ROW is
2631
+ // skipped. Dropping them from the buckets instead would leave the meter
2632
+ // ("13/18 set", computed from `availableOptions`) disagreeing with the box
2633
+ // badge beside it, which counts row entries.
2634
+ const absorbedBy: Record<string, string> = {};
2635
+ forEach(options, (option, optionName) => {
2636
+ const absorb = (option as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2637
+ if (!absorb?.length) return;
2638
+ absorb.forEach((absorbedName) => {
2639
+ // Only absorb a sibling that exists and is not already spoken for; a
2640
+ // schema naming a missing field must not make the field disappear.
2641
+ if (options?.[absorbedName] && !absorbedBy[absorbedName]) {
2642
+ absorbedBy[absorbedName] = optionName;
2643
+ }
2644
+ });
2645
+ });
2646
+
2561
2647
  // Group the options by their raw `group` key, remembering the order groups
2562
2648
  // first appear in the schema. Each row carries a `hidden` flag.
2563
2649
  const groupOrder: string[] = [];
@@ -2646,16 +2732,17 @@ const FormEngineImpl = ({
2646
2732
  // schema-group order (thin sub-labels) and the required-group clustering: an
2647
2733
  // unmet one-of group's members all land in attention and still rail up.
2648
2734
  type TRowEntry = { name: string; hidden: boolean };
2649
- const buckets: Record<TFormEngineBoxKey, Record<string, TRowEntry[]>> = {
2735
+ type TBucketKey = 'attention' | 'set' | 'optional';
2736
+ const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
2650
2737
  attention: {},
2651
2738
  set: {},
2652
2739
  optional: {},
2653
2740
  };
2654
- const bucketGroups: Record<TFormEngineBoxKey, string[]> = { attention: [], set: [], optional: [] };
2741
+ const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
2655
2742
  // Freeze the box of any field currently being edited (or whose one-of group
2656
2743
  // has an edited member) to its last settled box — so finishing an edit that
2657
2744
  // flips its status doesn't remount it in another box and steal focus.
2658
- const stableBucketOf = (entry: TRowEntry): TFormEngineBoxKey => {
2745
+ const stableBucketOf = (entry: TRowEntry): TBucketKey => {
2659
2746
  const fresh = getOptionBucket(entry.name, entry.hidden);
2660
2747
  const groupBeingEdited =
2661
2748
  !entry.hidden &&
@@ -2679,7 +2766,7 @@ const FormEngineImpl = ({
2679
2766
  buckets[b][groupName].push(entry);
2680
2767
  });
2681
2768
  });
2682
- const bucketCount = (b: TFormEngineBoxKey) =>
2769
+ const bucketCount = (b: TBucketKey) =>
2683
2770
  bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2684
2771
 
2685
2772
  // "Is the Optional box the whole form?" — when nothing needs attention and
@@ -2695,7 +2782,7 @@ const FormEngineImpl = ({
2695
2782
  const showGroupSubLabel = (groupName: string) =>
2696
2783
  (groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
2697
2784
  const STATUS_BOXES: Array<{
2698
- key: TFormEngineBoxKey;
2785
+ key: TBucketKey;
2699
2786
  label: string;
2700
2787
  intent?: 'warning' | 'success';
2701
2788
  icon: IReqoreIconName;
@@ -2709,6 +2796,11 @@ const FormEngineImpl = ({
2709
2796
  // together at the first member's slot and rendered as a connected rail (flat
2710
2797
  // rows — no wrapper — so the value surface applies normally; the rail + nodes
2711
2798
  // are drawn per member). Narrow stacks fall back to flat rows.
2799
+ // The absorbed siblings a host row must render, in schema order. Empty for
2800
+ // every row that absorbs nothing, which is all of them by default.
2801
+ const absorbedNamesFor = (hostName: string): string[] =>
2802
+ Object.keys(absorbedBy).filter((absorbedName) => absorbedBy[absorbedName] === hostName);
2803
+
2712
2804
  const renderGroupRows = (names: Array<{ name: string; hidden: boolean }>) => {
2713
2805
  const renderRow = (
2714
2806
  entry: { name: string; hidden?: boolean },
@@ -2731,6 +2823,9 @@ const FormEngineImpl = ({
2731
2823
  clustered={clustered}
2732
2824
  clusterFirst={clusterFirst}
2733
2825
  clusterLast={clusterLast}
2826
+ // The siblings this row renders inside itself instead of leaving
2827
+ // them a row of their own — see `absorbedBy`.
2828
+ absorbedFields={absorbedNamesFor(entry.name)}
2734
2829
  />
2735
2830
  );
2736
2831
  // (Clustering runs in narrow mode too now — the "One of the below is
@@ -2739,6 +2834,10 @@ const FormEngineImpl = ({
2739
2834
  const emitted = new Set<string>();
2740
2835
  const groupOf = (name: string) =>
2741
2836
  (options?.[name]?.required_groups as string[] | undefined)?.[0];
2837
+ // An absorbed field keeps its place in the buckets — so the Set /
2838
+ // Optional counts and the completion meter still see it — but its host
2839
+ // renders its control, so it gets no row of its own here.
2840
+ names = names.filter((entry) => !absorbedBy[entry.name]);
2742
2841
  return names.map((entry) => {
2743
2842
  const grp = groupOf(entry.name);
2744
2843
  if (!grp) return renderRow(entry, false);
@@ -2843,33 +2942,36 @@ const FormEngineImpl = ({
2843
2942
  $accent={accent}
2844
2943
  $bg={boxBg}
2845
2944
  key={box.key}
2945
+ ref={(node: HTMLDivElement | null) => {
2946
+ statusBoxRefs.current[box.key] = node;
2947
+ }}
2948
+ // Opening arms the reveal; collapsing disarms it, so a
2949
+ // close never scrolls.
2950
+ onCollapseChange={(collapsed) =>
2951
+ setOpenedStatusBox(collapsed ? undefined : box.key)
2952
+ }
2846
2953
  flat
2847
2954
  minimal
2848
2955
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
2849
2956
  collapsible
2850
- // Which boxes start closed is the consumer's call
2851
- // (`compactCollapsedGroups`, default `['optional']`: the
2852
- // Optional box holds every not-yet-added field, so a form
2853
- // opens on what is in use). Two rules override it:
2854
- //
2855
- // A SEARCH forces every box open — ReqorePanel unmounts
2856
- // collapsed content, so a match inside a closed box would
2857
- // be unreachable. (isCollapsed is the panel's controllable
2858
- // state; manual toggling still works with no query set.)
2859
- //
2860
- // The Optional box stays open when it is the ONLY box,
2861
- // because collapsing it renders a card that looks empty and
2862
- // broken: an auth profile's Authorization block, whose nine
2863
- // requirement fields are all optional, showed as a titled
2864
- // card containing one collapsed "Optional 9" strip and
2865
- // nothing else. This does not generalise to the other two —
2866
- // a lone Set box collapsed is a deliberate ask, and its
2867
- // header still reports what is in there.
2868
- isCollapsed={
2869
- compactCollapsedGroups.includes(box.key) &&
2870
- !query &&
2871
- !(box.key === 'optional' && onlyOptionalRows)
2872
- }
2957
+ // The Optional box now holds every not-yet-added field, so
2958
+ // it starts COLLAPSED to keep the form focused on what's in
2959
+ // use. But a SEARCH must surface matching addable fields —
2960
+ // and ReqorePanel unmounts collapsed content so force it
2961
+ // open whenever a query is active. (isCollapsed is the
2962
+ // panel's controllable state; manual toggling still works
2963
+ // when no query is set.)
2964
+ // The Optional box starts collapsed so a form opens on what
2965
+ // is in use but only when there IS something else to
2966
+ // open on. When every field is optional it is the whole
2967
+ // form, and collapsing it renders a card that looks empty
2968
+ // and broken: an auth profile's Authorization block, whose
2969
+ // nine requirement fields are all optional, showed as a
2970
+ // titled card containing one collapsed "Optional 9" strip
2971
+ // and nothing else. A search still forces it open
2972
+ // (ReqorePanel unmounts collapsed content, so a match
2973
+ // inside it would be unreachable otherwise).
2974
+ isCollapsed={box.key === 'optional' && !query && !onlyOptionalRows}
2873
2975
  label={
2874
2976
  <StyledGroupHeader>
2875
2977
  <ReqoreP effect={{ weight: 'bold' }} size='normal'>