@qoretechnologies/reqraft 0.10.45-pr.95.gc02205d → 0.10.45

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 (144) 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/composer/Composer.d.ts.map +1 -1
  5. package/dist/components/composer/Composer.js +5 -1
  6. package/dist/components/composer/Composer.js.map +1 -1
  7. package/dist/components/form/engine/CompactRow.d.ts +20 -1
  8. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  9. package/dist/components/form/engine/CompactRow.js +279 -41
  10. package/dist/components/form/engine/CompactRow.js.map +1 -1
  11. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  12. package/dist/components/form/engine/CompactToolbar.js +72 -72
  13. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  14. package/dist/components/form/engine/FormEngine.d.ts +48 -1
  15. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  16. package/dist/components/form/engine/FormEngine.js +203 -98
  17. package/dist/components/form/engine/FormEngine.js.map +1 -1
  18. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.js +55 -13
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  21. package/dist/components/form/engine/_structuredData/structuredData.d.ts +15 -0
  22. package/dist/components/form/engine/_structuredData/structuredData.d.ts.map +1 -1
  23. package/dist/components/form/engine/_structuredData/structuredData.js +22 -1
  24. package/dist/components/form/engine/_structuredData/structuredData.js.map +1 -1
  25. package/dist/components/form/engine/compactRowStyles.d.ts +2 -0
  26. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactRowStyles.js +21 -4
  28. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  29. package/dist/components/form/engine/compactToolbarContext.d.ts +8 -0
  30. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  31. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  32. package/dist/components/form/engine/readFirst.d.ts +24 -0
  33. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  34. package/dist/components/form/engine/readFirst.js +84 -6
  35. package/dist/components/form/engine/readFirst.js.map +1 -1
  36. package/dist/components/form/fields/Field.d.ts.map +1 -1
  37. package/dist/components/form/fields/Field.js +1 -1
  38. package/dist/components/form/fields/Field.js.map +1 -1
  39. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  40. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  41. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  42. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  43. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  44. package/dist/components/form/fields/auto/AutoFormField.js +64 -14
  45. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  46. package/dist/components/form/fields/long-string/LongString.d.ts +9 -1
  47. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  48. package/dist/components/form/fields/long-string/LongString.js +25 -4
  49. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  50. package/dist/components/form/fields/template/ExampleValueModal.d.ts +29 -0
  51. package/dist/components/form/fields/template/ExampleValueModal.d.ts.map +1 -0
  52. package/dist/components/form/fields/template/ExampleValueModal.js +122 -0
  53. package/dist/components/form/fields/template/ExampleValueModal.js.map +1 -0
  54. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  55. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -3
  56. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  57. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  58. package/dist/components/form/fields/template/TemplateField.js +35 -6
  59. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  60. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +2 -0
  61. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +1 -0
  62. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +9 -0
  63. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +1 -0
  64. package/dist/components/form/index.d.ts +1 -0
  65. package/dist/components/form/index.d.ts.map +1 -1
  66. package/dist/components/form/index.js +1 -0
  67. package/dist/components/form/index.js.map +1 -1
  68. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +12 -1
  69. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -1
  70. package/dist/components/searchFilterBar/SearchFilterBar.js +2 -2
  71. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -1
  72. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  73. package/dist/components/ticketThread/TicketThread.js +1 -1
  74. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  75. package/dist/helpers/common.d.ts +8 -1
  76. package/dist/helpers/common.d.ts.map +1 -1
  77. package/dist/helpers/common.js +9 -1
  78. package/dist/helpers/common.js.map +1 -1
  79. package/dist/helpers/optionUiTypes.d.ts +30 -0
  80. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  81. package/dist/helpers/optionUiTypes.js +156 -0
  82. package/dist/helpers/optionUiTypes.js.map +1 -0
  83. package/dist/helpers/options.d.ts +19 -0
  84. package/dist/helpers/options.d.ts.map +1 -1
  85. package/dist/helpers/options.js +25 -1
  86. package/dist/helpers/options.js.map +1 -1
  87. package/dist/helpers/providerValue.d.ts +68 -0
  88. package/dist/helpers/providerValue.d.ts.map +1 -0
  89. package/dist/helpers/providerValue.js +50 -0
  90. package/dist/helpers/providerValue.js.map +1 -0
  91. package/dist/helpers/singleLineString.d.ts +37 -0
  92. package/dist/helpers/singleLineString.d.ts.map +1 -0
  93. package/dist/helpers/singleLineString.js +66 -0
  94. package/dist/helpers/singleLineString.js.map +1 -0
  95. package/dist/helpers/templates.d.ts +100 -0
  96. package/dist/helpers/templates.d.ts.map +1 -1
  97. package/dist/helpers/templates.js +237 -4
  98. package/dist/helpers/templates.js.map +1 -1
  99. package/dist/helpers/validations.d.ts +103 -2
  100. package/dist/helpers/validations.d.ts.map +1 -1
  101. package/dist/helpers/validations.js +858 -91
  102. package/dist/helpers/validations.js.map +1 -1
  103. package/dist/index.d.ts +1 -1
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +2 -1
  106. package/dist/index.js.map +1 -1
  107. package/package.json +1 -1
  108. package/src/components/composer/Composer.tsx +7 -3
  109. package/src/components/form/engine/CompactRow.tsx +391 -29
  110. package/src/components/form/engine/CompactToolbar.tsx +105 -98
  111. package/src/components/form/engine/FormEngine.stories.tsx +863 -12
  112. package/src/components/form/engine/FormEngine.tsx +231 -68
  113. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +104 -0
  114. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +158 -101
  115. package/src/components/form/engine/_structuredData/structuredData.ts +20 -0
  116. package/src/components/form/engine/compactRowStyles.ts +38 -1
  117. package/src/components/form/engine/compactToolbarContext.ts +8 -0
  118. package/src/components/form/engine/readFirst.ts +125 -35
  119. package/src/components/form/fields/Field.tsx +3 -1
  120. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  121. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +81 -0
  122. package/src/components/form/fields/auto/AutoFormField.stories.tsx +47 -1
  123. package/src/components/form/fields/auto/AutoFormField.tsx +73 -11
  124. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  125. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +5 -0
  126. package/src/components/form/fields/template/ExampleValueModal.stories.tsx +114 -0
  127. package/src/components/form/fields/template/ExampleValueModal.tsx +99 -0
  128. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +8 -4
  129. package/src/components/form/fields/template/TemplateField.stories.tsx +314 -1
  130. package/src/components/form/fields/template/TemplateField.tsx +47 -8
  131. package/src/components/form/fields/template/__fixtures__/longExampleValue.ts +7 -0
  132. package/src/components/form/index.tsx +1 -0
  133. package/src/components/searchFilterBar/SearchFilterBar.stories.tsx +62 -0
  134. package/src/components/searchFilterBar/SearchFilterBar.tsx +13 -0
  135. package/src/components/ticketThread/TicketThread.tsx +7 -0
  136. package/src/helpers/common.ts +11 -1
  137. package/src/helpers/optionUiTypes.ts +161 -0
  138. package/src/helpers/options.ts +23 -0
  139. package/src/helpers/providerValue.ts +125 -0
  140. package/src/helpers/singleLineString.ts +61 -0
  141. package/src/helpers/templates.ts +272 -3
  142. package/src/helpers/validations.ts +1177 -109
  143. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  144. 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,
@@ -70,11 +75,7 @@ import {
70
75
  isValueTemplate,
71
76
  } from '../fields/template/TemplateField';
72
77
  import { CompactRow } from './CompactRow';
73
- import {
74
- CompactRowContext,
75
- ICompactRowContext,
76
- TCodePreviewRenderer,
77
- } from './compactRowContext';
78
+ import { CompactRowContext, ICompactRowContext, TCodePreviewRenderer } from './compactRowContext';
78
79
  import {
79
80
  GROUP_INDENT,
80
81
  LABEL_AFFORDANCE_WIDTH,
@@ -117,9 +118,25 @@ import {
117
118
  isOptionValueEmpty,
118
119
  isFixedCompactAllowedValueOption,
119
120
  shouldAutoCollapseCompactOption,
121
+ isUnsetSchemaHash,
120
122
  } from './readFirst';
121
123
 
122
124
  // Re-export types for consumers
125
+ /** The compact form's three status boxes. */
126
+ export type TFormEngineBoxKey = 'attention' | 'set' | 'optional';
127
+
128
+ /** Which parts of the compact toolbar to render — see `compactToolbar`. */
129
+ export interface IFormEngineToolbarParts {
130
+ /** The "Ready / 4 of 6 set / 67%" line and its meter. */
131
+ completion?: boolean;
132
+ /** The "Filter fields…" input. */
133
+ search?: boolean;
134
+ /** The field-picker dropdown (add optional fields, reset, revert). */
135
+ fields?: boolean;
136
+ /** The help button that opens the options dialog. */
137
+ help?: boolean;
138
+ }
139
+
123
140
  export type IOptions = TQorusForm;
124
141
  export type TFlatOptions = TQorusFlatForm;
125
142
  export type TOption = IQorusFormField;
@@ -133,8 +150,7 @@ export type TOperatorValue = TQorusFormOperatorValue;
133
150
  * consumer gets.
134
151
  */
135
152
  export interface IOptionFieldMessage
136
- extends IQorusFormFieldMessage,
137
- Pick<IConditionalFieldMessage, 'when' | 'unless'> {}
153
+ extends IQorusFormFieldMessage, Pick<IConditionalFieldMessage, 'when' | 'unless'> {}
138
154
  export type IOptionsSchemaArg = TQorusFormFieldSchema;
139
155
  export interface IOptionsSchema extends IQorusFormSchema {}
140
156
  export interface IOperator extends IQorusFormOperator {}
@@ -292,9 +308,10 @@ const getTypeFromOperator = (
292
308
  return (operators[last(operatorData) as string]?.type as TQorusType) || null;
293
309
  };
294
310
 
295
- export const fixOperatorValue = (operator: TOperatorValue): (string | null | undefined)[] => {
296
- return isArray(operator) ? operator : [operator];
297
- };
311
+ /** Re-exported from `helpers/common` so existing importers keep working; it
312
+ * lives there because `helpers/validations` needs it and cannot import this
313
+ * module (this module imports the validators). */
314
+ export { fixOperatorValue } from '../../../helpers/common';
298
315
 
299
316
  /**
300
317
  * The renderer-only predicate. Defaults to reqraft's built-ins; a FormEngine
@@ -307,11 +324,9 @@ const getOptionSchemaStorageType = (
307
324
  option?: TQorusFormFieldSchema,
308
325
  isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
309
326
  ): TQorusType =>
310
- ((isFixedCompactAllowedValueOption(option)
311
- ? option?.type || option?.ui_type
312
- : isRendererOnly(option?.ui_type)
313
- ? option?.type || option?.ui_type
314
- : option?.ui_type || option?.type) || 'any') as TQorusType;
327
+ ((isFixedCompactAllowedValueOption(option) ? option?.type || option?.ui_type
328
+ : isRendererOnly(option?.ui_type) ? option?.type || option?.ui_type
329
+ : option?.ui_type || option?.type) || 'any') as TQorusType;
315
330
 
316
331
  const getOptionFieldStorageType = (
317
332
  optionName: string,
@@ -323,9 +338,9 @@ const getOptionFieldStorageType = (
323
338
  ): TQorusType => {
324
339
  const schemaOption = schema?.[optionName];
325
340
  const storedType =
326
- fieldType && !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType))
327
- ? fieldType
328
- : getOptionSchemaStorageType(schemaOption, isRendererOnly);
341
+ fieldType && !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType)) ?
342
+ fieldType
343
+ : getOptionSchemaStorageType(schemaOption, isRendererOnly);
329
344
 
330
345
  return getType(storedType as TQorusType, operators, operatorData);
331
346
  };
@@ -373,6 +388,23 @@ export const fixOptions = (
373
388
  (fixedValue[name] as IQorusFormField)?.value === undefined
374
389
  ) {
375
390
  obj = option.default_value as IQorusFormField;
391
+ } else if (
392
+ option.default_view === 'expression' &&
393
+ (fixedValue[name] as IQorusFormField)?.value === undefined
394
+ ) {
395
+ // A field the schema declares as opening in expression mode starts as an
396
+ // EMPTY expression, not as its raw default. `isDefaultFunction` already
397
+ // makes the renderer open that way, so without this the data disagreed
398
+ // with what the operator was looking at: an expression editor whose
399
+ // value was still the plain default, and no `is_expression` on the field
400
+ // until the first edit.
401
+ obj = {
402
+ type,
403
+ value: {
404
+ args: [],
405
+ },
406
+ is_expression: true,
407
+ };
376
408
  } else {
377
409
  obj = {
378
410
  type,
@@ -438,9 +470,19 @@ export const fixOptions = (
438
470
  // every value declared the bare way: the collapsed row still showed its
439
471
  // display name while the editor showed "—" and the value never reached
440
472
  // the submitted data.
473
+ // `allowed_values: []` is NOT "no value is permitted" — it is the server
474
+ // saying it has no reference values to offer right now (the app-action
475
+ // catalogue ships exactly that, with an
476
+ // `option_reference_values_unavailable` message attached). An empty array
477
+ // is truthy, so testing the array itself made every such option erase the
478
+ // operator's stored value on the first render, before the
479
+ // connection-refreshed schema arrived to say otherwise — and the emptied
480
+ // form then autosaved over the draft, so the value was gone for good.
481
+ // Only an option that actually declares choices can have a value that is
482
+ // not one of them.
441
483
  if (
442
484
  newOption.value !== undefined &&
443
- options?.[optionName]?.allowed_values &&
485
+ options?.[optionName]?.allowed_values?.length &&
444
486
  !findAllowedValueOption(newOption.value, options?.[optionName]) &&
445
487
  !isValueTemplate(newOption.value) &&
446
488
  !options?.[optionName]?.multiselect &&
@@ -626,6 +668,37 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
626
668
  * No-op in classic (non-compact) mode.
627
669
  */
628
670
  compactPanelProps?: IReqorePanelProps;
671
+ /**
672
+ * Compact mode only: which status boxes start collapsed. Defaults to
673
+ * `['optional']` — a form opens on what is in use.
674
+ *
675
+ * Pass `['set', 'optional']` where the form is one item in a longer list and
676
+ * the reader is choosing WHICH to open rather than reading this one, e.g. a
677
+ * template's per-action option forms stacked in a drawer.
678
+ *
679
+ * Two escapes override this and are not negotiable: a box always opens while
680
+ * a search query is running (`ReqorePanel` unmounts collapsed content, so a
681
+ * match inside a closed box would be unreachable), and the Optional box stays
682
+ * open when every row is optional, because collapsing it would render a card
683
+ * that looks empty and broken.
684
+ */
685
+ compactCollapsedGroups?: TFormEngineBoxKey[];
686
+ /**
687
+ * Compact mode only: which parts of the form's toolbar to show. `true`
688
+ * (default) shows all of it; `false` hides the whole thing.
689
+ *
690
+ * The parts already hide themselves when they have nothing to say — the
691
+ * completion meter needs at least one counted option, and the search and
692
+ * field-picker need more than one option to pick between — so reach for this
693
+ * only to hide something that WOULD have rendered. A per-action form asking
694
+ * for one value, say, has a meter reading 0/1 that tells the reader nothing
695
+ * they cannot see in the row below it.
696
+ *
697
+ * Hiding `search` removes the only way to run a query, which is what forces
698
+ * a collapsed box open; `compactCollapsedGroups` then fully determines what
699
+ * is open.
700
+ */
701
+ compactToolbar?: boolean | IFormEngineToolbarParts;
629
702
  /** Compact mode only: per-group display metadata (label / icon / subtitle /
630
703
  * order) — the server only sends the bare group key. */
631
704
  groups?: Record<string, IFormEngineGroup>;
@@ -792,6 +865,8 @@ const FormEngineImpl = ({
792
865
  compactFlush = false,
793
866
  compactNested = false,
794
867
  compactPanelProps,
868
+ compactCollapsedGroups = ['optional'],
869
+ compactToolbar = true,
795
870
  commitMode = 'immediate',
796
871
  expandMode = 'single',
797
872
  onCommit,
@@ -1129,7 +1204,10 @@ const FormEngineImpl = ({
1129
1204
  setOptions({});
1130
1205
  return;
1131
1206
  }
1132
- setLocalValue({ fields: fixOptions(value, data.data, undefined, isRendererOnly), meta: undefined });
1207
+ setLocalValue({
1208
+ fields: fixOptions(value, data.data, undefined, isRendererOnly),
1209
+ meta: undefined,
1210
+ });
1133
1211
  if (!operatorsUrl) {
1134
1212
  setLoading(false);
1135
1213
  }
@@ -1174,7 +1252,10 @@ const FormEngineImpl = ({
1174
1252
  }
1175
1253
  setOptions(data.data);
1176
1254
  onOptionsLoaded?.(data.data);
1177
- setLocalValue({ fields: fixOptions({}, data.data, undefined, isRendererOnly), meta: undefined });
1255
+ setLocalValue({
1256
+ fields: fixOptions({}, data.data, undefined, isRendererOnly),
1257
+ meta: undefined,
1258
+ });
1178
1259
  })();
1179
1260
  }
1180
1261
  }, [url, customUrl]);
@@ -1231,12 +1312,14 @@ const FormEngineImpl = ({
1231
1312
  const isAnyLike = schemaType === 'any' || schemaType === 'auto';
1232
1313
  // For any/auto schema types, preserve the user's chosen type stored in the field
1233
1314
  const resolvedSchemaType =
1234
- isAnyLike && (fields[optionName] as IQorusFormField)?.type
1235
- ? ((fields[optionName] as IQorusFormField).type as TQorusType)
1236
- : (fields[optionName] as IQorusFormField)?.type === options?.[optionName]?.ui_type &&
1237
- isRendererOnly((fields[optionName] as IQorusFormField)?.type)
1238
- ? schemaType
1239
- : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
1315
+ isAnyLike && (fields[optionName] as IQorusFormField)?.type ?
1316
+ ((fields[optionName] as IQorusFormField).type as TQorusType)
1317
+ : (
1318
+ (fields[optionName] as IQorusFormField)?.type === options?.[optionName]?.ui_type &&
1319
+ isRendererOnly((fields[optionName] as IQorusFormField)?.type)
1320
+ ) ?
1321
+ schemaType
1322
+ : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
1240
1323
  const type =
1241
1324
  _type ||
1242
1325
  getTypeAndCanBeNull(resolvedSchemaType, options?.[optionName]?.allowed_values).type;
@@ -1310,11 +1393,7 @@ const FormEngineImpl = ({
1310
1393
 
1311
1394
  onSingleOptionsChange?.(optionName, updatedValue[optionName]);
1312
1395
 
1313
- if (
1314
- compact &&
1315
- !readOnly &&
1316
- shouldAutoCollapseCompactOption(options?.[optionName], val)
1317
- ) {
1396
+ if (compact && !readOnly && shouldAutoCollapseCompactOption(options?.[optionName], val)) {
1318
1397
  setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1319
1398
  }
1320
1399
 
@@ -1757,7 +1836,11 @@ const FormEngineImpl = ({
1757
1836
  isRendererOnly
1758
1837
  );
1759
1838
  const value = (availableOptions as TQorusForm)?.[name]?.value;
1760
- const empty = isOptionValueEmpty(value);
1839
+ // A schema-declared hash whose every entry is a materialised-but-unset
1840
+ // field holds nothing, so it is not "set" — without this the row showed a
1841
+ // green set-dot and counted toward the completion meter while the
1842
+ // sub-form it summarises said 0/3 set.
1843
+ const empty = isOptionValueEmpty(value) || isUnsetSchemaHash(value, schema);
1761
1844
  const reqGroups = (schema?.required_groups as string[] | undefined) || [];
1762
1845
  const required = !!(schema?.required || reqGroups.length);
1763
1846
  const covered =
@@ -2060,7 +2143,10 @@ const FormEngineImpl = ({
2060
2143
  next[optionName] = value as IQorusFormField;
2061
2144
  }
2062
2145
  });
2063
- return { fields: fixOptions(next, options || {}, operators, isRendererOnly), meta: undefined };
2146
+ return {
2147
+ fields: fixOptions(next, options || {}, operators, isRendererOnly),
2148
+ meta: undefined,
2149
+ };
2064
2150
  });
2065
2151
  setRequiredOnly(false);
2066
2152
  }, [JSON.stringify(options), JSON.stringify(operators), isRendererOnly]);
@@ -2108,11 +2194,10 @@ const FormEngineImpl = ({
2108
2194
  const schemaUiType = optionSchema?.ui_type as TQorusType;
2109
2195
  const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
2110
2196
  const resolvedType =
2111
- (isFixedCompactAllowedValueOption(optionSchema)
2112
- ? getOptionSchemaStorageType(optionSchema, isRendererOnly)
2113
- : schemaUiType && !uiTypeIsAnyLike
2114
- ? schemaUiType
2115
- : undefined) ||
2197
+ (isFixedCompactAllowedValueOption(optionSchema) ?
2198
+ getOptionSchemaStorageType(optionSchema, isRendererOnly)
2199
+ : schemaUiType && !uiTypeIsAnyLike ? schemaUiType
2200
+ : undefined) ||
2116
2201
  type ||
2117
2202
  schemaUiType ||
2118
2203
  (optionSchema?.type as TQorusType) ||
@@ -2307,6 +2392,11 @@ const FormEngineImpl = ({
2307
2392
  allowed_values_creatable={options?.[optionName]?.allowed_values_creatable}
2308
2393
  disabled={
2309
2394
  options?.[optionName]?.disabled ||
2395
+ // a system-owned field states that as `readonly`; only the older
2396
+ // compat projection also sent `disabled`, so honouring only
2397
+ // `disabled` rendered an editable control for a value the user
2398
+ // cannot change (see CompactRow's `fieldReadonly`)
2399
+ (options?.[optionName] as { readonly?: boolean } | undefined)?.readonly ||
2310
2400
  readOnly ||
2311
2401
  !hasAllDependenciesFullfilled(
2312
2402
  options?.[optionName]?.depends_on,
@@ -2503,6 +2593,14 @@ const FormEngineImpl = ({
2503
2593
  ]
2504
2594
  );
2505
2595
 
2596
+ // `compactToolbar` resolved to a full object, so neither the context type nor
2597
+ // the toolbar itself has to re-default anything. `null` means no toolbar.
2598
+ const compactToolbarParts = useMemo(() => {
2599
+ if (compactToolbar === false) return null;
2600
+ const all = { completion: true, search: true, fields: true, help: true };
2601
+ return compactToolbar === true ? all : { ...all, ...compactToolbar };
2602
+ }, [JSON.stringify(compactToolbar)]);
2603
+
2506
2604
  const compactToolbarContextValue = useMemo<ICompactToolbarContext>(
2507
2605
  () => ({
2508
2606
  readOnly,
@@ -2511,6 +2609,12 @@ const FormEngineImpl = ({
2511
2609
  completion: readFirstCompletion,
2512
2610
  showInvalidOnly: showInvalidOptionsOnly,
2513
2611
  onToggleInvalidOnly: handleToggleInvalidOnly,
2612
+ parts: compactToolbarParts ?? {
2613
+ completion: false,
2614
+ search: false,
2615
+ fields: false,
2616
+ help: false,
2617
+ },
2514
2618
  hasMultipleOptions: size(availableOptions) > 1,
2515
2619
  compactQuery,
2516
2620
  setCompactQuery,
@@ -2561,10 +2665,14 @@ const FormEngineImpl = ({
2561
2665
 
2562
2666
  // The engine's own toolbar action, plus whatever the consumer added through
2563
2667
  // `compactPanelProps.actions` — appended, so an outside action can never
2564
- // knock the form's toolbar out of the header.
2668
+ // knock the form's toolbar out of the header. Only `compactToolbar={false}`
2669
+ // removes it, and then the header carries just the consumer's own actions.
2565
2670
  const compactHeaderActions = useMemo(
2566
- () => [{ as: CompactToolbar, responsive: false }, ...(compactPanelProps?.actions || [])],
2567
- [compactPanelProps?.actions]
2671
+ () => [
2672
+ ...(compactToolbarParts ? [{ as: CompactToolbar, responsive: false }] : []),
2673
+ ...(compactPanelProps?.actions || []),
2674
+ ],
2675
+ [compactPanelProps?.actions, compactToolbarParts]
2568
2676
  );
2569
2677
  const renderCompact = () => {
2570
2678
  const headerBg = `${changeDarkness(getMainBackgroundColor(theme), 0.02)}${percentToHexAlpha(88)}`;
@@ -2577,9 +2685,40 @@ const FormEngineImpl = ({
2577
2685
  if (requiredOnly && !(schema?.required || schema?.required_groups)) {
2578
2686
  return false;
2579
2687
  }
2580
- return matchesQuery(optionName);
2688
+ // A field that absorbs a sibling also answers for it: searching
2689
+ // "language" has to surface the row that now holds the language control,
2690
+ // or the field becomes unreachable through the filter.
2691
+ if (matchesQuery(optionName)) return true;
2692
+ const absorb = (schema as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2693
+ return !!absorb?.some((absorbedName) => matchesQuery(absorbedName));
2581
2694
  };
2582
2695
 
2696
+ // Fields a sibling absorbs into its own container.
2697
+ //
2698
+ // A field declaring `absorb_fields: ['language']` renders that sibling's
2699
+ // control inside its own row instead of letting it have a row of its own.
2700
+ // The pair this exists for is a code editor and its language: they are one
2701
+ // decision — "what is this code, and in what language" — and reading them
2702
+ // as two unrelated rows one above the other is what the layout used to say.
2703
+ //
2704
+ // Absorbed fields stay in `grouped`, and therefore in the Set / Optional /
2705
+ // Needs-attention counts and in the completion meter. Only the ROW is
2706
+ // skipped. Dropping them from the buckets instead would leave the meter
2707
+ // ("13/18 set", computed from `availableOptions`) disagreeing with the box
2708
+ // badge beside it, which counts row entries.
2709
+ const absorbedBy: Record<string, string> = {};
2710
+ forEach(options, (option, optionName) => {
2711
+ const absorb = (option as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2712
+ if (!absorb?.length) return;
2713
+ absorb.forEach((absorbedName) => {
2714
+ // Only absorb a sibling that exists and is not already spoken for; a
2715
+ // schema naming a missing field must not make the field disappear.
2716
+ if (options?.[absorbedName] && !absorbedBy[absorbedName]) {
2717
+ absorbedBy[absorbedName] = optionName;
2718
+ }
2719
+ });
2720
+ });
2721
+
2583
2722
  // Group the options by their raw `group` key, remembering the order groups
2584
2723
  // first appear in the schema. Each row carries a `hidden` flag.
2585
2724
  const groupOrder: string[] = [];
@@ -2668,17 +2807,20 @@ const FormEngineImpl = ({
2668
2807
  // schema-group order (thin sub-labels) and the required-group clustering: an
2669
2808
  // unmet one-of group's members all land in attention and still rail up.
2670
2809
  type TRowEntry = { name: string; hidden: boolean };
2671
- type TBucketKey = 'attention' | 'set' | 'optional';
2672
- const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
2810
+ const buckets: Record<TFormEngineBoxKey, Record<string, TRowEntry[]>> = {
2673
2811
  attention: {},
2674
2812
  set: {},
2675
2813
  optional: {},
2676
2814
  };
2677
- const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
2815
+ const bucketGroups: Record<TFormEngineBoxKey, string[]> = {
2816
+ attention: [],
2817
+ set: [],
2818
+ optional: [],
2819
+ };
2678
2820
  // Freeze the box of any field currently being edited (or whose one-of group
2679
2821
  // has an edited member) to its last settled box — so finishing an edit that
2680
2822
  // flips its status doesn't remount it in another box and steal focus.
2681
- const stableBucketOf = (entry: TRowEntry): TBucketKey => {
2823
+ const stableBucketOf = (entry: TRowEntry): TFormEngineBoxKey => {
2682
2824
  const fresh = getOptionBucket(entry.name, entry.hidden);
2683
2825
  const groupBeingEdited =
2684
2826
  !entry.hidden &&
@@ -2702,7 +2844,7 @@ const FormEngineImpl = ({
2702
2844
  buckets[b][groupName].push(entry);
2703
2845
  });
2704
2846
  });
2705
- const bucketCount = (b: TBucketKey) =>
2847
+ const bucketCount = (b: TFormEngineBoxKey) =>
2706
2848
  bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2707
2849
 
2708
2850
  // "Is the Optional box the whole form?" — when nothing needs attention and
@@ -2718,7 +2860,7 @@ const FormEngineImpl = ({
2718
2860
  const showGroupSubLabel = (groupName: string) =>
2719
2861
  (groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
2720
2862
  const STATUS_BOXES: Array<{
2721
- key: TBucketKey;
2863
+ key: TFormEngineBoxKey;
2722
2864
  label: string;
2723
2865
  intent?: 'warning' | 'success';
2724
2866
  icon: IReqoreIconName;
@@ -2732,6 +2874,11 @@ const FormEngineImpl = ({
2732
2874
  // together at the first member's slot and rendered as a connected rail (flat
2733
2875
  // rows — no wrapper — so the value surface applies normally; the rail + nodes
2734
2876
  // are drawn per member). Narrow stacks fall back to flat rows.
2877
+ // The absorbed siblings a host row must render, in schema order. Empty for
2878
+ // every row that absorbs nothing, which is all of them by default.
2879
+ const absorbedNamesFor = (hostName: string): string[] =>
2880
+ Object.keys(absorbedBy).filter((absorbedName) => absorbedBy[absorbedName] === hostName);
2881
+
2735
2882
  const renderGroupRows = (names: Array<{ name: string; hidden: boolean }>) => {
2736
2883
  const renderRow = (
2737
2884
  entry: { name: string; hidden?: boolean },
@@ -2754,6 +2901,9 @@ const FormEngineImpl = ({
2754
2901
  clustered={clustered}
2755
2902
  clusterFirst={clusterFirst}
2756
2903
  clusterLast={clusterLast}
2904
+ // The siblings this row renders inside itself instead of leaving
2905
+ // them a row of their own — see `absorbedBy`.
2906
+ absorbedFields={absorbedNamesFor(entry.name)}
2757
2907
  />
2758
2908
  );
2759
2909
  // (Clustering runs in narrow mode too now — the "One of the below is
@@ -2762,6 +2912,10 @@ const FormEngineImpl = ({
2762
2912
  const emitted = new Set<string>();
2763
2913
  const groupOf = (name: string) =>
2764
2914
  (options?.[name]?.required_groups as string[] | undefined)?.[0];
2915
+ // An absorbed field keeps its place in the buckets — so the Set /
2916
+ // Optional counts and the completion meter still see it — but its host
2917
+ // renders its control, so it gets no row of its own here.
2918
+ names = names.filter((entry) => !absorbedBy[entry.name]);
2765
2919
  return names.map((entry) => {
2766
2920
  const grp = groupOf(entry.name);
2767
2921
  if (!grp) return renderRow(entry, false);
@@ -2812,6 +2966,10 @@ const FormEngineImpl = ({
2812
2966
  $flush={compactFlush || compactNested}
2813
2967
  >
2814
2968
  <StyledCompactPanel
2969
+ // Mirrors the toolbar's own search-row gate: when no search
2970
+ // row renders, the header is a thin strip and sits tight to
2971
+ // the first status box (see StyledCompactPanel).
2972
+ $tightHeader={!(compactToolbarParts?.search && size(availableOptions) > 1)}
2815
2973
  // The top-level form scrolls, so its toolbar STICKS and carries a
2816
2974
  // dark blurred backdrop so content ghosts cleanly beneath it. A
2817
2975
  // nested (arg_schema) sub-form owns no scroll context — drop the
@@ -2878,24 +3036,29 @@ const FormEngineImpl = ({
2878
3036
  minimal
2879
3037
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
2880
3038
  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}
3039
+ // Which boxes start closed is the consumer's call
3040
+ // (`compactCollapsedGroups`, default `['optional']`: the
3041
+ // Optional box holds every not-yet-added field, so a form
3042
+ // opens on what is in use). Two rules override it:
3043
+ //
3044
+ // A SEARCH forces every box open — ReqorePanel unmounts
3045
+ // collapsed content, so a match inside a closed box would
3046
+ // be unreachable. (isCollapsed is the panel's controllable
3047
+ // state; manual toggling still works with no query set.)
3048
+ //
3049
+ // The Optional box stays open when it is the ONLY box,
3050
+ // because collapsing it renders a card that looks empty and
3051
+ // broken: an auth profile's Authorization block, whose nine
3052
+ // requirement fields are all optional, showed as a titled
3053
+ // card containing one collapsed "Optional 9" strip and
3054
+ // nothing else. This does not generalise to the other two —
3055
+ // a lone Set box collapsed is a deliberate ask, and its
3056
+ // header still reports what is in there.
3057
+ isCollapsed={
3058
+ compactCollapsedGroups.includes(box.key) &&
3059
+ !query &&
3060
+ !(box.key === 'optional' && onlyOptionalRows)
3061
+ }
2899
3062
  label={
2900
3063
  <StyledGroupHeader>
2901
3064
  <ReqoreP effect={{ weight: 'bold' }} size='normal'>
@@ -55,6 +55,78 @@ const methodValue = [
55
55
  { type: 'hash', value: { name: 'onConnect', desc: 'connection lifecycle hook' } },
56
56
  ];
57
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
+
58
130
  const colors = { key: '#f1f0ee', muted: '#a0a0a0', border: '#a0a0a066', accent: '#3b8eea' };
59
131
 
60
132
  export const Schemes: Story = {
@@ -127,3 +199,35 @@ export const Undescribed: Story = {
127
199
  await expect(label!.offsetHeight).toBeLessThan(lineHeight * 1.6);
128
200
  },
129
201
  };
202
+
203
+ /**
204
+ * The deeper case: hash values holding hash values.
205
+ *
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 = {
212
+ parameters: {
213
+ docs: {
214
+ description: {
215
+ story:
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.',
217
+ },
218
+ },
219
+ },
220
+ args: { value: nestedValue, schema: NestedSchema, colors },
221
+ play: async ({ canvasElement }) => {
222
+ const titles = [...canvasElement.querySelectorAll('.schema-view-item-title')].map((element) =>
223
+ (element.textContent ?? '').trim()
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]');
232
+ },
233
+ };