@qoretechnologies/reqraft 0.10.41-pr.95.g294e5aa → 0.10.41

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 (137) hide show
  1. package/.claude/CLAUDE.md +12 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/TEMPLATE_TOKEN_GRAMMAR.md +72 -0
  4. package/dist/components/codeSize/index.d.ts +37 -0
  5. package/dist/components/codeSize/index.d.ts.map +1 -0
  6. package/dist/components/codeSize/index.js +87 -0
  7. package/dist/components/codeSize/index.js.map +1 -0
  8. package/dist/components/form/engine/CompactRow.d.ts +20 -1
  9. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  10. package/dist/components/form/engine/CompactRow.js +284 -42
  11. package/dist/components/form/engine/CompactRow.js.map +1 -1
  12. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  13. package/dist/components/form/engine/CompactToolbar.js +72 -72
  14. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  15. package/dist/components/form/engine/FormEngine.d.ts +48 -1
  16. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  17. package/dist/components/form/engine/FormEngine.js +163 -75
  18. package/dist/components/form/engine/FormEngine.js.map +1 -1
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js +102 -87
  21. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  22. package/dist/components/form/engine/compactRowStyles.d.ts +2 -0
  23. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  24. package/dist/components/form/engine/compactRowStyles.js +22 -5
  25. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  26. package/dist/components/form/engine/compactToolbarContext.d.ts +8 -0
  27. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  28. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  29. package/dist/components/form/engine/readFirst.d.ts +57 -1
  30. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  31. package/dist/components/form/engine/readFirst.js +143 -4
  32. package/dist/components/form/engine/readFirst.js.map +1 -1
  33. package/dist/components/form/fields/Field.d.ts.map +1 -1
  34. package/dist/components/form/fields/Field.js +1 -1
  35. package/dist/components/form/fields/Field.js.map +1 -1
  36. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  37. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  38. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  39. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  40. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  41. package/dist/components/form/fields/array/ArrayAuto.js +44 -26
  42. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  43. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  44. package/dist/components/form/fields/array/ArrayAutoField.js +42 -25
  45. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  46. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  47. package/dist/components/form/fields/auto/AutoFormField.js +32 -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/ExampleValueModal.d.ts +29 -0
  54. package/dist/components/form/fields/template/ExampleValueModal.d.ts.map +1 -0
  55. package/dist/components/form/fields/template/ExampleValueModal.js +122 -0
  56. package/dist/components/form/fields/template/ExampleValueModal.js.map +1 -0
  57. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  58. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -3
  59. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  60. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  61. package/dist/components/form/fields/template/TemplateField.js +35 -6
  62. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  63. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +2 -0
  64. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +1 -0
  65. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +9 -0
  66. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +1 -0
  67. package/dist/components/form/index.d.ts +1 -0
  68. package/dist/components/form/index.d.ts.map +1 -1
  69. package/dist/components/form/index.js +1 -0
  70. package/dist/components/form/index.js.map +1 -1
  71. package/dist/helpers/common.d.ts +8 -1
  72. package/dist/helpers/common.d.ts.map +1 -1
  73. package/dist/helpers/common.js +9 -1
  74. package/dist/helpers/common.js.map +1 -1
  75. package/dist/helpers/optionUiTypes.d.ts +30 -0
  76. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  77. package/dist/helpers/optionUiTypes.js +148 -0
  78. package/dist/helpers/optionUiTypes.js.map +1 -0
  79. package/dist/helpers/options.d.ts +19 -0
  80. package/dist/helpers/options.d.ts.map +1 -1
  81. package/dist/helpers/options.js +25 -1
  82. package/dist/helpers/options.js.map +1 -1
  83. package/dist/helpers/providerValue.d.ts +68 -0
  84. package/dist/helpers/providerValue.d.ts.map +1 -0
  85. package/dist/helpers/providerValue.js +50 -0
  86. package/dist/helpers/providerValue.js.map +1 -0
  87. package/dist/helpers/singleLineString.d.ts +37 -0
  88. package/dist/helpers/singleLineString.d.ts.map +1 -0
  89. package/dist/helpers/singleLineString.js +66 -0
  90. package/dist/helpers/singleLineString.js.map +1 -0
  91. package/dist/helpers/templates.d.ts +100 -0
  92. package/dist/helpers/templates.d.ts.map +1 -1
  93. package/dist/helpers/templates.js +237 -4
  94. package/dist/helpers/templates.js.map +1 -1
  95. package/dist/helpers/validations.d.ts +103 -2
  96. package/dist/helpers/validations.d.ts.map +1 -1
  97. package/dist/helpers/validations.js +858 -91
  98. package/dist/helpers/validations.js.map +1 -1
  99. package/dist/index.d.ts +2 -1
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +3 -1
  102. package/dist/index.js.map +1 -1
  103. package/package.json +1 -1
  104. package/src/components/codeSize/index.tsx +79 -0
  105. package/src/components/form/engine/CompactRow.tsx +407 -34
  106. package/src/components/form/engine/CompactToolbar.tsx +105 -98
  107. package/src/components/form/engine/FormEngine.stories.tsx +863 -12
  108. package/src/components/form/engine/FormEngine.tsx +183 -33
  109. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +110 -103
  110. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +132 -80
  111. package/src/components/form/engine/compactRowStyles.ts +44 -1
  112. package/src/components/form/engine/compactToolbarContext.ts +8 -0
  113. package/src/components/form/engine/readFirst.ts +163 -4
  114. package/src/components/form/fields/Field.tsx +3 -1
  115. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  116. package/src/components/form/fields/array/ArrayAuto.tsx +23 -4
  117. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +134 -0
  118. package/src/components/form/fields/array/ArrayAutoField.tsx +22 -4
  119. package/src/components/form/fields/auto/AutoFormField.tsx +31 -1
  120. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  121. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +5 -0
  122. package/src/components/form/fields/template/ExampleValueModal.stories.tsx +114 -0
  123. package/src/components/form/fields/template/ExampleValueModal.tsx +99 -0
  124. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +8 -4
  125. package/src/components/form/fields/template/TemplateField.stories.tsx +314 -1
  126. package/src/components/form/fields/template/TemplateField.tsx +47 -8
  127. package/src/components/form/fields/template/__fixtures__/longExampleValue.ts +7 -0
  128. package/src/components/form/index.tsx +1 -0
  129. package/src/helpers/common.ts +11 -1
  130. package/src/helpers/optionUiTypes.ts +153 -0
  131. package/src/helpers/options.ts +23 -0
  132. package/src/helpers/providerValue.ts +125 -0
  133. package/src/helpers/singleLineString.ts +61 -0
  134. package/src/helpers/templates.ts +272 -3
  135. package/src/helpers/validations.ts +1177 -109
  136. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  137. package/src/index.tsx +2 -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,
@@ -120,6 +125,21 @@ import {
120
125
  } from './readFirst';
121
126
 
122
127
  // Re-export types for consumers
128
+ /** The compact form's three status boxes. */
129
+ export type TFormEngineBoxKey = 'attention' | 'set' | 'optional';
130
+
131
+ /** Which parts of the compact toolbar to render — see `compactToolbar`. */
132
+ export interface IFormEngineToolbarParts {
133
+ /** The "Ready / 4 of 6 set / 67%" line and its meter. */
134
+ completion?: boolean;
135
+ /** The "Filter fields…" input. */
136
+ search?: boolean;
137
+ /** The field-picker dropdown (add optional fields, reset, revert). */
138
+ fields?: boolean;
139
+ /** The help button that opens the options dialog. */
140
+ help?: boolean;
141
+ }
142
+
123
143
  export type IOptions = TQorusForm;
124
144
  export type TFlatOptions = TQorusFlatForm;
125
145
  export type TOption = IQorusFormField;
@@ -292,9 +312,10 @@ const getTypeFromOperator = (
292
312
  return (operators[last(operatorData) as string]?.type as TQorusType) || null;
293
313
  };
294
314
 
295
- export const fixOperatorValue = (operator: TOperatorValue): (string | null | undefined)[] => {
296
- return isArray(operator) ? operator : [operator];
297
- };
315
+ /** Re-exported from `helpers/common` so existing importers keep working; it
316
+ * lives there because `helpers/validations` needs it and cannot import this
317
+ * module (this module imports the validators). */
318
+ export { fixOperatorValue } from '../../../helpers/common';
298
319
 
299
320
  /**
300
321
  * The renderer-only predicate. Defaults to reqraft's built-ins; a FormEngine
@@ -373,6 +394,23 @@ export const fixOptions = (
373
394
  (fixedValue[name] as IQorusFormField)?.value === undefined
374
395
  ) {
375
396
  obj = option.default_value as IQorusFormField;
397
+ } else if (
398
+ option.default_view === 'expression' &&
399
+ (fixedValue[name] as IQorusFormField)?.value === undefined
400
+ ) {
401
+ // A field the schema declares as opening in expression mode starts as an
402
+ // EMPTY expression, not as its raw default. `isDefaultFunction` already
403
+ // makes the renderer open that way, so without this the data disagreed
404
+ // with what the operator was looking at: an expression editor whose
405
+ // value was still the plain default, and no `is_expression` on the field
406
+ // until the first edit.
407
+ obj = {
408
+ type,
409
+ value: {
410
+ args: [],
411
+ },
412
+ is_expression: true,
413
+ };
376
414
  } else {
377
415
  obj = {
378
416
  type,
@@ -438,9 +476,19 @@ export const fixOptions = (
438
476
  // every value declared the bare way: the collapsed row still showed its
439
477
  // display name while the editor showed "—" and the value never reached
440
478
  // the submitted data.
479
+ // `allowed_values: []` is NOT "no value is permitted" — it is the server
480
+ // saying it has no reference values to offer right now (the app-action
481
+ // catalogue ships exactly that, with an
482
+ // `option_reference_values_unavailable` message attached). An empty array
483
+ // is truthy, so testing the array itself made every such option erase the
484
+ // operator's stored value on the first render, before the
485
+ // connection-refreshed schema arrived to say otherwise — and the emptied
486
+ // form then autosaved over the draft, so the value was gone for good.
487
+ // Only an option that actually declares choices can have a value that is
488
+ // not one of them.
441
489
  if (
442
490
  newOption.value !== undefined &&
443
- options?.[optionName]?.allowed_values &&
491
+ options?.[optionName]?.allowed_values?.length &&
444
492
  !findAllowedValueOption(newOption.value, options?.[optionName]) &&
445
493
  !isValueTemplate(newOption.value) &&
446
494
  !options?.[optionName]?.multiselect &&
@@ -626,6 +674,37 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
626
674
  * No-op in classic (non-compact) mode.
627
675
  */
628
676
  compactPanelProps?: IReqorePanelProps;
677
+ /**
678
+ * Compact mode only: which status boxes start collapsed. Defaults to
679
+ * `['optional']` — a form opens on what is in use.
680
+ *
681
+ * Pass `['set', 'optional']` where the form is one item in a longer list and
682
+ * the reader is choosing WHICH to open rather than reading this one, e.g. a
683
+ * template's per-action option forms stacked in a drawer.
684
+ *
685
+ * Two escapes override this and are not negotiable: a box always opens while
686
+ * a search query is running (`ReqorePanel` unmounts collapsed content, so a
687
+ * match inside a closed box would be unreachable), and the Optional box stays
688
+ * open when every row is optional, because collapsing it would render a card
689
+ * that looks empty and broken.
690
+ */
691
+ compactCollapsedGroups?: TFormEngineBoxKey[];
692
+ /**
693
+ * Compact mode only: which parts of the form's toolbar to show. `true`
694
+ * (default) shows all of it; `false` hides the whole thing.
695
+ *
696
+ * The parts already hide themselves when they have nothing to say — the
697
+ * completion meter needs at least one counted option, and the search and
698
+ * field-picker need more than one option to pick between — so reach for this
699
+ * only to hide something that WOULD have rendered. A per-action form asking
700
+ * for one value, say, has a meter reading 0/1 that tells the reader nothing
701
+ * they cannot see in the row below it.
702
+ *
703
+ * Hiding `search` removes the only way to run a query, which is what forces
704
+ * a collapsed box open; `compactCollapsedGroups` then fully determines what
705
+ * is open.
706
+ */
707
+ compactToolbar?: boolean | IFormEngineToolbarParts;
629
708
  /** Compact mode only: per-group display metadata (label / icon / subtitle /
630
709
  * order) — the server only sends the bare group key. */
631
710
  groups?: Record<string, IFormEngineGroup>;
@@ -792,6 +871,8 @@ const FormEngineImpl = ({
792
871
  compactFlush = false,
793
872
  compactNested = false,
794
873
  compactPanelProps,
874
+ compactCollapsedGroups = ['optional'],
875
+ compactToolbar = true,
795
876
  commitMode = 'immediate',
796
877
  expandMode = 'single',
797
878
  onCommit,
@@ -2307,6 +2388,11 @@ const FormEngineImpl = ({
2307
2388
  allowed_values_creatable={options?.[optionName]?.allowed_values_creatable}
2308
2389
  disabled={
2309
2390
  options?.[optionName]?.disabled ||
2391
+ // a system-owned field states that as `readonly`; only the older
2392
+ // compat projection also sent `disabled`, so honouring only
2393
+ // `disabled` rendered an editable control for a value the user
2394
+ // cannot change (see CompactRow's `fieldReadonly`)
2395
+ (options?.[optionName] as { readonly?: boolean } | undefined)?.readonly ||
2310
2396
  readOnly ||
2311
2397
  !hasAllDependenciesFullfilled(
2312
2398
  options?.[optionName]?.depends_on,
@@ -2503,6 +2589,14 @@ const FormEngineImpl = ({
2503
2589
  ]
2504
2590
  );
2505
2591
 
2592
+ // `compactToolbar` resolved to a full object, so neither the context type nor
2593
+ // the toolbar itself has to re-default anything. `null` means no toolbar.
2594
+ const compactToolbarParts = useMemo(() => {
2595
+ if (compactToolbar === false) return null;
2596
+ const all = { completion: true, search: true, fields: true, help: true };
2597
+ return compactToolbar === true ? all : { ...all, ...compactToolbar };
2598
+ }, [JSON.stringify(compactToolbar)]);
2599
+
2506
2600
  const compactToolbarContextValue = useMemo<ICompactToolbarContext>(
2507
2601
  () => ({
2508
2602
  readOnly,
@@ -2511,6 +2605,7 @@ const FormEngineImpl = ({
2511
2605
  completion: readFirstCompletion,
2512
2606
  showInvalidOnly: showInvalidOptionsOnly,
2513
2607
  onToggleInvalidOnly: handleToggleInvalidOnly,
2608
+ parts: compactToolbarParts ?? { completion: false, search: false, fields: false, help: false },
2514
2609
  hasMultipleOptions: size(availableOptions) > 1,
2515
2610
  compactQuery,
2516
2611
  setCompactQuery,
@@ -2561,10 +2656,14 @@ const FormEngineImpl = ({
2561
2656
 
2562
2657
  // The engine's own toolbar action, plus whatever the consumer added through
2563
2658
  // `compactPanelProps.actions` — appended, so an outside action can never
2564
- // knock the form's toolbar out of the header.
2659
+ // knock the form's toolbar out of the header. Only `compactToolbar={false}`
2660
+ // removes it, and then the header carries just the consumer's own actions.
2565
2661
  const compactHeaderActions = useMemo(
2566
- () => [{ as: CompactToolbar, responsive: false }, ...(compactPanelProps?.actions || [])],
2567
- [compactPanelProps?.actions]
2662
+ () => [
2663
+ ...(compactToolbarParts ? [{ as: CompactToolbar, responsive: false }] : []),
2664
+ ...(compactPanelProps?.actions || []),
2665
+ ],
2666
+ [compactPanelProps?.actions, compactToolbarParts]
2568
2667
  );
2569
2668
  const renderCompact = () => {
2570
2669
  const headerBg = `${changeDarkness(getMainBackgroundColor(theme), 0.02)}${percentToHexAlpha(88)}`;
@@ -2577,9 +2676,40 @@ const FormEngineImpl = ({
2577
2676
  if (requiredOnly && !(schema?.required || schema?.required_groups)) {
2578
2677
  return false;
2579
2678
  }
2580
- return matchesQuery(optionName);
2679
+ // A field that absorbs a sibling also answers for it: searching
2680
+ // "language" has to surface the row that now holds the language control,
2681
+ // or the field becomes unreachable through the filter.
2682
+ if (matchesQuery(optionName)) return true;
2683
+ const absorb = (schema as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2684
+ return !!absorb?.some((absorbedName) => matchesQuery(absorbedName));
2581
2685
  };
2582
2686
 
2687
+ // Fields a sibling absorbs into its own container.
2688
+ //
2689
+ // A field declaring `absorb_fields: ['language']` renders that sibling's
2690
+ // control inside its own row instead of letting it have a row of its own.
2691
+ // The pair this exists for is a code editor and its language: they are one
2692
+ // decision — "what is this code, and in what language" — and reading them
2693
+ // as two unrelated rows one above the other is what the layout used to say.
2694
+ //
2695
+ // Absorbed fields stay in `grouped`, and therefore in the Set / Optional /
2696
+ // Needs-attention counts and in the completion meter. Only the ROW is
2697
+ // skipped. Dropping them from the buckets instead would leave the meter
2698
+ // ("13/18 set", computed from `availableOptions`) disagreeing with the box
2699
+ // badge beside it, which counts row entries.
2700
+ const absorbedBy: Record<string, string> = {};
2701
+ forEach(options, (option, optionName) => {
2702
+ const absorb = (option as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2703
+ if (!absorb?.length) return;
2704
+ absorb.forEach((absorbedName) => {
2705
+ // Only absorb a sibling that exists and is not already spoken for; a
2706
+ // schema naming a missing field must not make the field disappear.
2707
+ if (options?.[absorbedName] && !absorbedBy[absorbedName]) {
2708
+ absorbedBy[absorbedName] = optionName;
2709
+ }
2710
+ });
2711
+ });
2712
+
2583
2713
  // Group the options by their raw `group` key, remembering the order groups
2584
2714
  // first appear in the schema. Each row carries a `hidden` flag.
2585
2715
  const groupOrder: string[] = [];
@@ -2668,17 +2798,16 @@ const FormEngineImpl = ({
2668
2798
  // schema-group order (thin sub-labels) and the required-group clustering: an
2669
2799
  // unmet one-of group's members all land in attention and still rail up.
2670
2800
  type TRowEntry = { name: string; hidden: boolean };
2671
- type TBucketKey = 'attention' | 'set' | 'optional';
2672
- const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
2801
+ const buckets: Record<TFormEngineBoxKey, Record<string, TRowEntry[]>> = {
2673
2802
  attention: {},
2674
2803
  set: {},
2675
2804
  optional: {},
2676
2805
  };
2677
- const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
2806
+ const bucketGroups: Record<TFormEngineBoxKey, string[]> = { attention: [], set: [], optional: [] };
2678
2807
  // Freeze the box of any field currently being edited (or whose one-of group
2679
2808
  // has an edited member) to its last settled box — so finishing an edit that
2680
2809
  // flips its status doesn't remount it in another box and steal focus.
2681
- const stableBucketOf = (entry: TRowEntry): TBucketKey => {
2810
+ const stableBucketOf = (entry: TRowEntry): TFormEngineBoxKey => {
2682
2811
  const fresh = getOptionBucket(entry.name, entry.hidden);
2683
2812
  const groupBeingEdited =
2684
2813
  !entry.hidden &&
@@ -2702,7 +2831,7 @@ const FormEngineImpl = ({
2702
2831
  buckets[b][groupName].push(entry);
2703
2832
  });
2704
2833
  });
2705
- const bucketCount = (b: TBucketKey) =>
2834
+ const bucketCount = (b: TFormEngineBoxKey) =>
2706
2835
  bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2707
2836
 
2708
2837
  // "Is the Optional box the whole form?" — when nothing needs attention and
@@ -2718,7 +2847,7 @@ const FormEngineImpl = ({
2718
2847
  const showGroupSubLabel = (groupName: string) =>
2719
2848
  (groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
2720
2849
  const STATUS_BOXES: Array<{
2721
- key: TBucketKey;
2850
+ key: TFormEngineBoxKey;
2722
2851
  label: string;
2723
2852
  intent?: 'warning' | 'success';
2724
2853
  icon: IReqoreIconName;
@@ -2732,6 +2861,11 @@ const FormEngineImpl = ({
2732
2861
  // together at the first member's slot and rendered as a connected rail (flat
2733
2862
  // rows — no wrapper — so the value surface applies normally; the rail + nodes
2734
2863
  // are drawn per member). Narrow stacks fall back to flat rows.
2864
+ // The absorbed siblings a host row must render, in schema order. Empty for
2865
+ // every row that absorbs nothing, which is all of them by default.
2866
+ const absorbedNamesFor = (hostName: string): string[] =>
2867
+ Object.keys(absorbedBy).filter((absorbedName) => absorbedBy[absorbedName] === hostName);
2868
+
2735
2869
  const renderGroupRows = (names: Array<{ name: string; hidden: boolean }>) => {
2736
2870
  const renderRow = (
2737
2871
  entry: { name: string; hidden?: boolean },
@@ -2754,6 +2888,9 @@ const FormEngineImpl = ({
2754
2888
  clustered={clustered}
2755
2889
  clusterFirst={clusterFirst}
2756
2890
  clusterLast={clusterLast}
2891
+ // The siblings this row renders inside itself instead of leaving
2892
+ // them a row of their own — see `absorbedBy`.
2893
+ absorbedFields={absorbedNamesFor(entry.name)}
2757
2894
  />
2758
2895
  );
2759
2896
  // (Clustering runs in narrow mode too now — the "One of the below is
@@ -2762,6 +2899,10 @@ const FormEngineImpl = ({
2762
2899
  const emitted = new Set<string>();
2763
2900
  const groupOf = (name: string) =>
2764
2901
  (options?.[name]?.required_groups as string[] | undefined)?.[0];
2902
+ // An absorbed field keeps its place in the buckets — so the Set /
2903
+ // Optional counts and the completion meter still see it — but its host
2904
+ // renders its control, so it gets no row of its own here.
2905
+ names = names.filter((entry) => !absorbedBy[entry.name]);
2765
2906
  return names.map((entry) => {
2766
2907
  const grp = groupOf(entry.name);
2767
2908
  if (!grp) return renderRow(entry, false);
@@ -2812,6 +2953,10 @@ const FormEngineImpl = ({
2812
2953
  $flush={compactFlush || compactNested}
2813
2954
  >
2814
2955
  <StyledCompactPanel
2956
+ // Mirrors the toolbar's own search-row gate: when no search
2957
+ // row renders, the header is a thin strip and sits tight to
2958
+ // the first status box (see StyledCompactPanel).
2959
+ $tightHeader={!(compactToolbarParts?.search && size(availableOptions) > 1)}
2815
2960
  // The top-level form scrolls, so its toolbar STICKS and carries a
2816
2961
  // dark blurred backdrop so content ghosts cleanly beneath it. A
2817
2962
  // nested (arg_schema) sub-form owns no scroll context — drop the
@@ -2878,24 +3023,29 @@ const FormEngineImpl = ({
2878
3023
  minimal
2879
3024
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
2880
3025
  collapsible
2881
- // The Optional box now holds every not-yet-added field, so
2882
- // it starts COLLAPSED to keep the form focused on what's in
2883
- // use. But a SEARCH must surface matching addable fields —
2884
- // and ReqorePanel unmounts collapsed content — so force it
2885
- // open whenever a query is active. (isCollapsed is the
2886
- // panel's controllable state; manual toggling still works
2887
- // when no query is set.)
2888
- // The Optional box starts collapsed so a form opens on what
2889
- // is in use — but only when there IS something else to
2890
- // open on. When every field is optional it is the whole
2891
- // form, and collapsing it renders a card that looks empty
2892
- // and broken: an auth profile's Authorization block, whose
2893
- // nine requirement fields are all optional, showed as a
2894
- // titled card containing one collapsed "Optional 9" strip
2895
- // and nothing else. A search still forces it open
2896
- // (ReqorePanel unmounts collapsed content, so a match
2897
- // inside it would be unreachable otherwise).
2898
- isCollapsed={box.key === 'optional' && !query && !onlyOptionalRows}
3026
+ // Which boxes start closed is the consumer's call
3027
+ // (`compactCollapsedGroups`, default `['optional']`: the
3028
+ // Optional box holds every not-yet-added field, so a form
3029
+ // opens on what is in use). Two rules override it:
3030
+ //
3031
+ // A SEARCH forces every box open — ReqorePanel unmounts
3032
+ // collapsed content, so a match inside a closed box would
3033
+ // be unreachable. (isCollapsed is the panel's controllable
3034
+ // state; manual toggling still works with no query set.)
3035
+ //
3036
+ // The Optional box stays open when it is the ONLY box,
3037
+ // because collapsing it renders a card that looks empty and
3038
+ // broken: an auth profile's Authorization block, whose nine
3039
+ // requirement fields are all optional, showed as a titled
3040
+ // card containing one collapsed "Optional 9" strip and
3041
+ // nothing else. This does not generalise to the other two —
3042
+ // a lone Set box collapsed is a deliberate ask, and its
3043
+ // header still reports what is in there.
3044
+ isCollapsed={
3045
+ compactCollapsedGroups.includes(box.key) &&
3046
+ !query &&
3047
+ !(box.key === 'optional' && onlyOptionalRows)
3048
+ }
2899
3049
  label={
2900
3050
  <StyledGroupHeader>
2901
3051
  <ReqoreP effect={{ weight: 'bold' }} size='normal'>
@@ -1,7 +1,5 @@
1
- import { ReqoreControlGroup, ReqoreP, ReqorePanel } from '@qoretechnologies/reqore';
2
1
  import { Meta, StoryObj } from '@storybook/react-vite';
3
2
  import { expect } from 'storybook/test';
4
- import styled from 'styled-components';
5
3
  import { SchemaDataView } from './SchemaDataView';
6
4
 
7
5
  /**
@@ -57,6 +55,78 @@ const methodValue = [
57
55
  { type: 'hash', value: { name: 'onConnect', desc: 'connection lifecycle hook' } },
58
56
  ];
59
57
 
58
+ /**
59
+ * A schema whose hash values hold hash values of their own — three levels, not
60
+ * one.
61
+ *
62
+ * Every other fixture here is a flat record, and a flat record cannot show what
63
+ * this view does with depth: whether a nested level is legible, whether the
64
+ * rules stack sensibly, whether a heading two levels down still reads as a
65
+ * heading. Judging the treatment on a single `key: value` pair is what the
66
+ * review said was impossible, and it was right.
67
+ */
68
+ const NestedSchema = {
69
+ name: { type: 'string', display_name: 'Endpoint' },
70
+ auth: {
71
+ type: 'hash',
72
+ display_name: 'Authentication',
73
+ arg_schema: {
74
+ scheme: {
75
+ type: 'string',
76
+ display_name: 'Scheme',
77
+ allowed_values: [
78
+ { value: 'oauth2', display_name: 'OAuth2' },
79
+ { value: 'basic', display_name: 'Basic' },
80
+ ],
81
+ },
82
+ token: {
83
+ type: 'hash',
84
+ display_name: 'Token',
85
+ arg_schema: {
86
+ url: { type: 'string', display_name: 'Token URL' },
87
+ ttl: { type: 'int', display_name: 'Lifetime (s)' },
88
+ rotate: { type: 'bool', display_name: 'Rotate Automatically' },
89
+ },
90
+ },
91
+ },
92
+ },
93
+ retry: {
94
+ type: 'hash',
95
+ display_name: 'Retry Policy',
96
+ arg_schema: {
97
+ attempts: { type: 'int', display_name: 'Attempts' },
98
+ backoff: { type: 'string', display_name: 'Backoff' },
99
+ },
100
+ },
101
+ } as never;
102
+
103
+ const nestedValue = [
104
+ {
105
+ type: 'hash',
106
+ value: {
107
+ name: 'POST /orders',
108
+ auth: {
109
+ scheme: 'oauth2',
110
+ token: {
111
+ url: 'https://auth.example.com/oauth2/token',
112
+ ttl: 3600,
113
+ rotate: true,
114
+ },
115
+ },
116
+ retry: { attempts: 3, backoff: 'exponential' },
117
+ },
118
+ },
119
+ {
120
+ // A second record that fills only part of the tree: the nested level is
121
+ // present but shallow, which is where "no data under the label" shows up.
122
+ type: 'hash',
123
+ value: {
124
+ name: 'GET /orders/{id}',
125
+ auth: { scheme: 'basic' },
126
+ },
127
+ },
128
+ ];
129
+
60
130
  const colors = { key: '#f1f0ee', muted: '#a0a0a0', border: '#a0a0a066', accent: '#3b8eea' };
61
131
 
62
132
  export const Schemes: Story = {
@@ -108,119 +178,56 @@ export const Undescribed: Story = {
108
178
  },
109
179
  play: async ({ canvasElement }) => {
110
180
  await expect(canvasElement.textContent).toContain('legacy_flag');
181
+
182
+ // A field name renders on ONE line. The label column is measured from the
183
+ // longest name, and the label carries `overflow-wrap: anywhere` — so an
184
+ // under-measured column does not ellipsise, it breaks the name in half.
185
+ // That is exactly what happened when the labels became uppercase and the
186
+ // measurement was still in `ch`, which knows nothing about the tracking:
187
+ // `LEGACY_FLAG` came out over two lines.
188
+ //
189
+ // Asserted as geometry rather than as a width, because the width is the
190
+ // thing that keeps being wrong — the invariant is that the name fits.
191
+ const label = [...canvasElement.querySelectorAll<HTMLElement>('.schema-view-fields > *')].find(
192
+ (element) => (element.textContent ?? '').trim().toLowerCase() === 'legacy_flag'
193
+ );
194
+
195
+ await expect(label).toBeTruthy();
196
+
197
+ const lineHeight = parseFloat(getComputedStyle(label!).lineHeight) || 16;
198
+
199
+ await expect(label!.offsetHeight).toBeLessThan(lineHeight * 1.6);
111
200
  },
112
201
  };
113
202
 
114
- /* ------------------------------------------------------------------------- *
115
- * Heading-emphasis prototypes
116
- *
117
- * The identifying value is the thing a reader scans for, and size alone was not
118
- * carrying it far enough. These render the SAME data under candidate
119
- * treatments, side by side, so the choice is made by looking rather than by
120
- * describing.
203
+ /**
204
+ * The deeper case: hash values holding hash values.
121
205
  *
122
- * The overrides live in the story, NOT in the component: this is a comparison,
123
- * and only the winner should reach `SchemaDataView`. Raw colours are used here
124
- * for the same reason — a prototype is allowed to be literal, but the shipped
125
- * version takes its colour from a Reqore intent (see BRAND_DESIGN §1: colour
126
- * meaning comes from intents, emphasis from effects, never per-app hex).
127
- * ------------------------------------------------------------------------- */
128
-
129
- const StyledVariant = styled.div`
130
- /* B — tinted background: the heading sits on its own surface. */
131
- &.variant-background .schema-view-item-title {
132
- display: inline-block;
133
- background: #ffffff0f;
134
- border-radius: 4px;
135
- padding: 1px 8px;
136
- margin-left: -8px;
137
- }
138
-
139
- /* C — indented rows: the heading keeps the left edge to itself, and the
140
- supporting fields step in under it. Costs no colour at all. */
141
- &.variant-indent .schema-view-fields {
142
- padding-left: 14px;
143
- }
144
-
145
- /* D — accent bar: a rule of its own beside the heading, in the app's accent.
146
- Sanctioned emphasis (BRAND_DESIGN §1 names accent bars explicitly). */
147
- &.variant-accent .schema-view-item-title {
148
- border-left: 3px solid #7c5cff;
149
- padding-left: 8px;
150
- margin-left: -11px;
151
- }
152
-
153
- /* E — everything: accent bar + tinted surface + indented rows. */
154
- &.variant-all .schema-view-item-title {
155
- display: inline-block;
156
- background: #7c5cff1f;
157
- border-left: 3px solid #7c5cff;
158
- border-radius: 0 4px 4px 0;
159
- padding: 1px 8px;
160
- margin-left: -11px;
161
- }
162
- &.variant-all .schema-view-fields {
163
- padding-left: 14px;
164
- }
165
-
166
- /* F — colour only, applied as a FILL behind the text rather than to the text
167
- itself: an intent used as text fails contrast (a muted label reads at
168
- 1.3:1), which is why this is a chip and not a coloured word. */
169
- &.variant-tint .schema-view-item-title {
170
- display: inline-block;
171
- background: #2e7d5b33;
172
- border-radius: 4px;
173
- padding: 1px 8px;
174
- margin-left: -8px;
175
- }
176
- `;
177
-
178
- const VARIANTS: { key: string; label: string; note: string }[] = [
179
- { key: 'variant-none', label: 'A — shipped treatment', note: 'accent bar + tinted fill + indented rows' },
180
- { key: 'variant-indent', label: 'B — indent the rows', note: 'no colour; heading owns the edge' },
181
- { key: 'variant-background', label: 'C — neutral fill', note: 'white 6% chip behind the name' },
182
- { key: 'variant-accent', label: 'D — accent bar', note: 'brand bar beside the name' },
183
- { key: 'variant-tint', label: 'E — coloured fill', note: 'intent as a FILL, never as text' },
184
- { key: 'variant-all', label: 'F — bar + fill + indent', note: 'all three together' },
185
- ];
186
-
187
- export const HeadingEmphasisPrototypes: Story = {
206
+ * Asked for in review because the flat fixtures could not answer whether the
207
+ * treatment survives nesting — three levels here (endpoint → authentication →
208
+ * token), plus a second record that stops after one, so a nested level with
209
+ * nothing under it renders beside one that is full.
210
+ */
211
+ export const NestedSchemas: Story = {
188
212
  parameters: {
189
- // Six variants stacked do not fit the default 720px capture, and a
190
- // comparison you have to scroll is not a comparison. Qlip's capture viewport
191
- // is a separate setting from Storybook's preview viewport, so it is pinned
192
- // here explicitly — setting only the preview one yields a full-height story
193
- // and a cropped snapshot.
194
- qlip: { viewport: { width: 1280, height: 1100 } },
195
213
  docs: {
196
214
  description: {
197
215
  story:
198
- 'Candidate treatments for the item heading, rendered over identical data so they can be compared directly. A prototype surface — only the chosen treatment is meant to reach the component.',
216
+ 'A described list whose records nest two levels deeper: an endpoint carrying an Authentication hash, which itself carries a Token hash of URL, lifetime and a bool. The second record fills only the first nested level, so a shallow branch sits next to a full one.',
199
217
  },
200
218
  },
201
219
  },
202
- // Two items per variant, not three: six stacked variants have to fit one
203
- // screen or they cannot be compared at a glance, which is the whole point.
204
- args: { value: methodValue.slice(0, 2), schema: MethodSchema, colors },
205
- render: (args) => (
206
- <ReqoreControlGroup vertical fluid gapSize='big'>
207
- {VARIANTS.map((variant) => (
208
- <ReqorePanel key={variant.key} label={variant.label} minimal flat padded size='small'>
209
- <ReqoreP size='small' effect={{ opacity: 0.6 }} style={{ marginBottom: 6 }}>
210
- {variant.note}
211
- </ReqoreP>
212
- <StyledVariant className={variant.key}>
213
- <SchemaDataView {...args} />
214
- </StyledVariant>
215
- </ReqorePanel>
216
- ))}
217
- </ReqoreControlGroup>
218
- ),
220
+ args: { value: nestedValue, schema: NestedSchema, colors },
219
221
  play: async ({ canvasElement }) => {
220
- // Every variant renders the same three items — the comparison is only
221
- // meaningful if none of the treatments changed the content.
222
- await expect(canvasElement.querySelectorAll('.schema-view-item-title')).toHaveLength(
223
- VARIANTS.length * 2
222
+ const titles = [...canvasElement.querySelectorAll('.schema-view-item-title')].map((element) =>
223
+ (element.textContent ?? '').trim()
224
224
  );
225
+
226
+ await expect(titles).toEqual(['POST /orders', 'GET /orders/{id}']);
227
+ // The deepest level actually rendered: a label from the third tier, and the
228
+ // bool beneath it resolved to a word rather than printed as `true`.
229
+ await expect(canvasElement.textContent).toContain('Token URL');
230
+ await expect(canvasElement.textContent).toContain('Lifetime (s)');
231
+ await expect(canvasElement.textContent).not.toContain('[object Object]');
225
232
  },
226
233
  };