@qoretechnologies/reqraft 0.10.46-pr.95.ge595a13 → 0.10.46

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 (147) 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 +14 -0
  8. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  9. package/dist/components/form/engine/CompactRow.js +199 -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 +65 -1
  15. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  16. package/dist/components/form/engine/FormEngine.js +276 -108
  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.map +1 -1
  26. package/dist/components/form/engine/compactRowStyles.js +15 -3
  27. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  28. package/dist/components/form/engine/compactToolbarContext.d.ts +8 -0
  29. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  30. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  31. package/dist/components/form/engine/readFirst.d.ts +24 -0
  32. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  33. package/dist/components/form/engine/readFirst.js +84 -6
  34. package/dist/components/form/engine/readFirst.js.map +1 -1
  35. package/dist/components/form/fields/Field.d.ts.map +1 -1
  36. package/dist/components/form/fields/Field.js +1 -1
  37. package/dist/components/form/fields/Field.js.map +1 -1
  38. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  39. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  40. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  41. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  42. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  43. package/dist/components/form/fields/auto/AutoFormField.js +64 -14
  44. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  45. package/dist/components/form/fields/long-string/LongString.d.ts +9 -1
  46. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  47. package/dist/components/form/fields/long-string/LongString.js +25 -4
  48. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  49. package/dist/components/form/fields/template/ExampleValueModal.d.ts +29 -0
  50. package/dist/components/form/fields/template/ExampleValueModal.d.ts.map +1 -0
  51. package/dist/components/form/fields/template/ExampleValueModal.js +122 -0
  52. package/dist/components/form/fields/template/ExampleValueModal.js.map +1 -0
  53. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  54. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -3
  55. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  56. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  57. package/dist/components/form/fields/template/TemplateField.js +81 -27
  58. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  59. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +2 -0
  60. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +1 -0
  61. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +9 -0
  62. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +1 -0
  63. package/dist/components/form/index.d.ts +1 -0
  64. package/dist/components/form/index.d.ts.map +1 -1
  65. package/dist/components/form/index.js +1 -0
  66. package/dist/components/form/index.js.map +1 -1
  67. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +12 -1
  68. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -1
  69. package/dist/components/searchFilterBar/SearchFilterBar.js +2 -2
  70. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -1
  71. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  72. package/dist/components/ticketThread/TicketThread.js +1 -1
  73. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  74. package/dist/helpers/common.d.ts +8 -1
  75. package/dist/helpers/common.d.ts.map +1 -1
  76. package/dist/helpers/common.js +9 -1
  77. package/dist/helpers/common.js.map +1 -1
  78. package/dist/helpers/expressions.d.ts.map +1 -1
  79. package/dist/helpers/expressions.js +15 -2
  80. package/dist/helpers/expressions.js.map +1 -1
  81. package/dist/helpers/optionUiTypes.d.ts +30 -0
  82. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  83. package/dist/helpers/optionUiTypes.js +156 -0
  84. package/dist/helpers/optionUiTypes.js.map +1 -0
  85. package/dist/helpers/options.d.ts +19 -0
  86. package/dist/helpers/options.d.ts.map +1 -1
  87. package/dist/helpers/options.js +25 -1
  88. package/dist/helpers/options.js.map +1 -1
  89. package/dist/helpers/providerValue.d.ts +68 -0
  90. package/dist/helpers/providerValue.d.ts.map +1 -0
  91. package/dist/helpers/providerValue.js +50 -0
  92. package/dist/helpers/providerValue.js.map +1 -0
  93. package/dist/helpers/singleLineString.d.ts +37 -0
  94. package/dist/helpers/singleLineString.d.ts.map +1 -0
  95. package/dist/helpers/singleLineString.js +66 -0
  96. package/dist/helpers/singleLineString.js.map +1 -0
  97. package/dist/helpers/templates.d.ts +100 -0
  98. package/dist/helpers/templates.d.ts.map +1 -1
  99. package/dist/helpers/templates.js +237 -4
  100. package/dist/helpers/templates.js.map +1 -1
  101. package/dist/helpers/validations.d.ts +103 -2
  102. package/dist/helpers/validations.d.ts.map +1 -1
  103. package/dist/helpers/validations.js +858 -91
  104. package/dist/helpers/validations.js.map +1 -1
  105. package/dist/index.d.ts +1 -1
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +2 -1
  108. package/dist/index.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/components/composer/Composer.tsx +7 -3
  111. package/src/components/form/engine/CompactRow.tsx +279 -33
  112. package/src/components/form/engine/CompactToolbar.tsx +105 -98
  113. package/src/components/form/engine/FormEngine.stories.tsx +995 -12
  114. package/src/components/form/engine/FormEngine.tsx +377 -94
  115. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +104 -0
  116. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +158 -101
  117. package/src/components/form/engine/_structuredData/structuredData.ts +20 -0
  118. package/src/components/form/engine/compactRowStyles.ts +55 -1
  119. package/src/components/form/engine/compactToolbarContext.ts +8 -0
  120. package/src/components/form/engine/readFirst.ts +125 -35
  121. package/src/components/form/fields/Field.tsx +3 -1
  122. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  123. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +81 -0
  124. package/src/components/form/fields/auto/AutoFormField.stories.tsx +47 -1
  125. package/src/components/form/fields/auto/AutoFormField.tsx +73 -11
  126. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  127. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +5 -0
  128. package/src/components/form/fields/template/ExampleValueModal.stories.tsx +114 -0
  129. package/src/components/form/fields/template/ExampleValueModal.tsx +99 -0
  130. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +8 -4
  131. package/src/components/form/fields/template/TemplateField.stories.tsx +389 -1
  132. package/src/components/form/fields/template/TemplateField.tsx +100 -35
  133. package/src/components/form/fields/template/__fixtures__/longExampleValue.ts +7 -0
  134. package/src/components/form/index.tsx +1 -0
  135. package/src/components/searchFilterBar/SearchFilterBar.stories.tsx +62 -0
  136. package/src/components/searchFilterBar/SearchFilterBar.tsx +13 -0
  137. package/src/components/ticketThread/TicketThread.tsx +7 -0
  138. package/src/helpers/common.ts +11 -1
  139. package/src/helpers/expressions.ts +16 -2
  140. package/src/helpers/optionUiTypes.ts +161 -0
  141. package/src/helpers/options.ts +23 -0
  142. package/src/helpers/providerValue.ts +125 -0
  143. package/src/helpers/singleLineString.ts +61 -0
  144. package/src/helpers/templates.ts +272 -3
  145. package/src/helpers/validations.ts +1177 -109
  146. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  147. 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>;
@@ -762,6 +835,24 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
762
835
  * (non-compact) mode.
763
836
  */
764
837
  initialExpandedOptions?: string[];
838
+
839
+ /**
840
+ * Cap how many rows a status box shows before the rest fold behind a
841
+ * "N more fields" control. Applies to every box — Needs attention, Set and
842
+ * Optional alike — so a long form stays scannable instead of making the
843
+ * reader page past one box to reach the next.
844
+ *
845
+ * Optional keeps its own rule on top of this: a box that would have
846
+ * collapsed but holds preselected rows shows those rows rather than an
847
+ * arbitrary first-N. The cap then applies to that set, so
848
+ * `maxFieldsShown: 5` over 10 preselected fields shows 5 — and opening the
849
+ * control reveals EVERY row in the box, not just the other 5 preselected
850
+ * ones, because once the reader has asked to see more, hiding the rest is
851
+ * the same defect one level down.
852
+ *
853
+ * Unset (the default) leaves every box uncapped.
854
+ */
855
+ maxFieldsShown?: number;
765
856
  }
766
857
 
767
858
  // Option types rendered full-width (IDE Options parity, commit 8e6b7781).
@@ -792,6 +883,8 @@ const FormEngineImpl = ({
792
883
  compactFlush = false,
793
884
  compactNested = false,
794
885
  compactPanelProps,
886
+ compactCollapsedGroups = ['optional'],
887
+ compactToolbar = true,
795
888
  commitMode = 'immediate',
796
889
  expandMode = 'single',
797
890
  onCommit,
@@ -812,6 +905,7 @@ const FormEngineImpl = ({
812
905
  autoFocusFirstRequired,
813
906
  expandFirstRequired,
814
907
  initialExpandedOptions,
908
+ maxFieldsShown,
815
909
  ...rest
816
910
  }: IFormEngineProps) => {
817
911
  // Built-ins + whatever the consumer declared for its own injected editors.
@@ -859,6 +953,9 @@ const FormEngineImpl = ({
859
953
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
860
954
  // Which options are expanded into their editor (several can be open at once).
861
955
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
956
+ /** Status boxes whose "N more fields" control the reader has opened. Keyed by
957
+ box so opening Optional does not also unfold Set. */
958
+ const [expandedMoreBoxes, setExpandedMoreBoxes] = useState<TFormEngineBoxKey[]>([]);
862
959
 
863
960
  // --- Reveal a status box's content when it is opened ----------------------
864
961
  //
@@ -1129,7 +1226,10 @@ const FormEngineImpl = ({
1129
1226
  setOptions({});
1130
1227
  return;
1131
1228
  }
1132
- setLocalValue({ fields: fixOptions(value, data.data, undefined, isRendererOnly), meta: undefined });
1229
+ setLocalValue({
1230
+ fields: fixOptions(value, data.data, undefined, isRendererOnly),
1231
+ meta: undefined,
1232
+ });
1133
1233
  if (!operatorsUrl) {
1134
1234
  setLoading(false);
1135
1235
  }
@@ -1174,7 +1274,10 @@ const FormEngineImpl = ({
1174
1274
  }
1175
1275
  setOptions(data.data);
1176
1276
  onOptionsLoaded?.(data.data);
1177
- setLocalValue({ fields: fixOptions({}, data.data, undefined, isRendererOnly), meta: undefined });
1277
+ setLocalValue({
1278
+ fields: fixOptions({}, data.data, undefined, isRendererOnly),
1279
+ meta: undefined,
1280
+ });
1178
1281
  })();
1179
1282
  }
1180
1283
  }, [url, customUrl]);
@@ -1231,12 +1334,14 @@ const FormEngineImpl = ({
1231
1334
  const isAnyLike = schemaType === 'any' || schemaType === 'auto';
1232
1335
  // For any/auto schema types, preserve the user's chosen type stored in the field
1233
1336
  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);
1337
+ isAnyLike && (fields[optionName] as IQorusFormField)?.type ?
1338
+ ((fields[optionName] as IQorusFormField).type as TQorusType)
1339
+ : (
1340
+ (fields[optionName] as IQorusFormField)?.type === options?.[optionName]?.ui_type &&
1341
+ isRendererOnly((fields[optionName] as IQorusFormField)?.type)
1342
+ ) ?
1343
+ schemaType
1344
+ : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
1240
1345
  const type =
1241
1346
  _type ||
1242
1347
  getTypeAndCanBeNull(resolvedSchemaType, options?.[optionName]?.allowed_values).type;
@@ -1310,11 +1415,7 @@ const FormEngineImpl = ({
1310
1415
 
1311
1416
  onSingleOptionsChange?.(optionName, updatedValue[optionName]);
1312
1417
 
1313
- if (
1314
- compact &&
1315
- !readOnly &&
1316
- shouldAutoCollapseCompactOption(options?.[optionName], val)
1317
- ) {
1418
+ if (compact && !readOnly && shouldAutoCollapseCompactOption(options?.[optionName], val)) {
1318
1419
  setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1319
1420
  }
1320
1421
 
@@ -1757,7 +1858,11 @@ const FormEngineImpl = ({
1757
1858
  isRendererOnly
1758
1859
  );
1759
1860
  const value = (availableOptions as TQorusForm)?.[name]?.value;
1760
- const empty = isOptionValueEmpty(value);
1861
+ // A schema-declared hash whose every entry is a materialised-but-unset
1862
+ // field holds nothing, so it is not "set" — without this the row showed a
1863
+ // green set-dot and counted toward the completion meter while the
1864
+ // sub-form it summarises said 0/3 set.
1865
+ const empty = isOptionValueEmpty(value) || isUnsetSchemaHash(value, schema);
1761
1866
  const reqGroups = (schema?.required_groups as string[] | undefined) || [];
1762
1867
  const required = !!(schema?.required || reqGroups.length);
1763
1868
  const covered =
@@ -2060,7 +2165,10 @@ const FormEngineImpl = ({
2060
2165
  next[optionName] = value as IQorusFormField;
2061
2166
  }
2062
2167
  });
2063
- return { fields: fixOptions(next, options || {}, operators, isRendererOnly), meta: undefined };
2168
+ return {
2169
+ fields: fixOptions(next, options || {}, operators, isRendererOnly),
2170
+ meta: undefined,
2171
+ };
2064
2172
  });
2065
2173
  setRequiredOnly(false);
2066
2174
  }, [JSON.stringify(options), JSON.stringify(operators), isRendererOnly]);
@@ -2108,11 +2216,10 @@ const FormEngineImpl = ({
2108
2216
  const schemaUiType = optionSchema?.ui_type as TQorusType;
2109
2217
  const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
2110
2218
  const resolvedType =
2111
- (isFixedCompactAllowedValueOption(optionSchema)
2112
- ? getOptionSchemaStorageType(optionSchema, isRendererOnly)
2113
- : schemaUiType && !uiTypeIsAnyLike
2114
- ? schemaUiType
2115
- : undefined) ||
2219
+ (isFixedCompactAllowedValueOption(optionSchema) ?
2220
+ getOptionSchemaStorageType(optionSchema, isRendererOnly)
2221
+ : schemaUiType && !uiTypeIsAnyLike ? schemaUiType
2222
+ : undefined) ||
2116
2223
  type ||
2117
2224
  schemaUiType ||
2118
2225
  (optionSchema?.type as TQorusType) ||
@@ -2307,6 +2414,11 @@ const FormEngineImpl = ({
2307
2414
  allowed_values_creatable={options?.[optionName]?.allowed_values_creatable}
2308
2415
  disabled={
2309
2416
  options?.[optionName]?.disabled ||
2417
+ // a system-owned field states that as `readonly`; only the older
2418
+ // compat projection also sent `disabled`, so honouring only
2419
+ // `disabled` rendered an editable control for a value the user
2420
+ // cannot change (see CompactRow's `fieldReadonly`)
2421
+ (options?.[optionName] as { readonly?: boolean } | undefined)?.readonly ||
2310
2422
  readOnly ||
2311
2423
  !hasAllDependenciesFullfilled(
2312
2424
  options?.[optionName]?.depends_on,
@@ -2400,6 +2512,9 @@ const FormEngineImpl = ({
2400
2512
  const cBg = (theme as { main?: string } | undefined)?.main || '#181818';
2401
2513
  const cRowBg = changeDarkness(getMainBackgroundColor(theme), 0.01);
2402
2514
  const cGroupLine = `${cText}1f`;
2515
+ // The "N more fields" row's dashed edge. Well above the divider tint: this
2516
+ // one is an affordance the reader has to notice, not a hairline between rows.
2517
+ const cMoreBorder = `${cText}4d`;
2403
2518
 
2404
2519
  // The closure surface the extracted CompactRow reads through context. Refs and
2405
2520
  // setters are stable; the state/memo/handler fields change identity as they do
@@ -2503,6 +2618,14 @@ const FormEngineImpl = ({
2503
2618
  ]
2504
2619
  );
2505
2620
 
2621
+ // `compactToolbar` resolved to a full object, so neither the context type nor
2622
+ // the toolbar itself has to re-default anything. `null` means no toolbar.
2623
+ const compactToolbarParts = useMemo(() => {
2624
+ if (compactToolbar === false) return null;
2625
+ const all = { completion: true, search: true, fields: true, help: true };
2626
+ return compactToolbar === true ? all : { ...all, ...compactToolbar };
2627
+ }, [JSON.stringify(compactToolbar)]);
2628
+
2506
2629
  const compactToolbarContextValue = useMemo<ICompactToolbarContext>(
2507
2630
  () => ({
2508
2631
  readOnly,
@@ -2511,6 +2634,12 @@ const FormEngineImpl = ({
2511
2634
  completion: readFirstCompletion,
2512
2635
  showInvalidOnly: showInvalidOptionsOnly,
2513
2636
  onToggleInvalidOnly: handleToggleInvalidOnly,
2637
+ parts: compactToolbarParts ?? {
2638
+ completion: false,
2639
+ search: false,
2640
+ fields: false,
2641
+ help: false,
2642
+ },
2514
2643
  hasMultipleOptions: size(availableOptions) > 1,
2515
2644
  compactQuery,
2516
2645
  setCompactQuery,
@@ -2561,10 +2690,14 @@ const FormEngineImpl = ({
2561
2690
 
2562
2691
  // The engine's own toolbar action, plus whatever the consumer added through
2563
2692
  // `compactPanelProps.actions` — appended, so an outside action can never
2564
- // knock the form's toolbar out of the header.
2693
+ // knock the form's toolbar out of the header. Only `compactToolbar={false}`
2694
+ // removes it, and then the header carries just the consumer's own actions.
2565
2695
  const compactHeaderActions = useMemo(
2566
- () => [{ as: CompactToolbar, responsive: false }, ...(compactPanelProps?.actions || [])],
2567
- [compactPanelProps?.actions]
2696
+ () => [
2697
+ ...(compactToolbarParts ? [{ as: CompactToolbar, responsive: false }] : []),
2698
+ ...(compactPanelProps?.actions || []),
2699
+ ],
2700
+ [compactPanelProps?.actions, compactToolbarParts]
2568
2701
  );
2569
2702
  const renderCompact = () => {
2570
2703
  const headerBg = `${changeDarkness(getMainBackgroundColor(theme), 0.02)}${percentToHexAlpha(88)}`;
@@ -2699,17 +2832,20 @@ const FormEngineImpl = ({
2699
2832
  // schema-group order (thin sub-labels) and the required-group clustering: an
2700
2833
  // unmet one-of group's members all land in attention and still rail up.
2701
2834
  type TRowEntry = { name: string; hidden: boolean };
2702
- type TBucketKey = 'attention' | 'set' | 'optional';
2703
- const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
2835
+ const buckets: Record<TFormEngineBoxKey, Record<string, TRowEntry[]>> = {
2704
2836
  attention: {},
2705
2837
  set: {},
2706
2838
  optional: {},
2707
2839
  };
2708
- const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
2840
+ const bucketGroups: Record<TFormEngineBoxKey, string[]> = {
2841
+ attention: [],
2842
+ set: [],
2843
+ optional: [],
2844
+ };
2709
2845
  // Freeze the box of any field currently being edited (or whose one-of group
2710
2846
  // has an edited member) to its last settled box — so finishing an edit that
2711
2847
  // flips its status doesn't remount it in another box and steal focus.
2712
- const stableBucketOf = (entry: TRowEntry): TBucketKey => {
2848
+ const stableBucketOf = (entry: TRowEntry): TFormEngineBoxKey => {
2713
2849
  const fresh = getOptionBucket(entry.name, entry.hidden);
2714
2850
  const groupBeingEdited =
2715
2851
  !entry.hidden &&
@@ -2733,7 +2869,7 @@ const FormEngineImpl = ({
2733
2869
  buckets[b][groupName].push(entry);
2734
2870
  });
2735
2871
  });
2736
- const bucketCount = (b: TBucketKey) =>
2872
+ const bucketCount = (b: TFormEngineBoxKey) =>
2737
2873
  bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2738
2874
 
2739
2875
  // "Is the Optional box the whole form?" — when nothing needs attention and
@@ -2748,8 +2884,32 @@ const FormEngineImpl = ({
2748
2884
  // other and DOES get its sub-label.
2749
2885
  const showGroupSubLabel = (groupName: string) =>
2750
2886
  (groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
2887
+
2888
+ // `preselected` means "show this option by default when a new form is
2889
+ // loaded, so it's visible to the user". An empty non-required preselected
2890
+ // field buckets to 'optional' (nothing is wrong with it, and it has no
2891
+ // value), and the Optional box is collapsed by default — so the very
2892
+ // fields the schema asked to put in front of the author were the ones
2893
+ // nobody could see. A preselected row therefore forces its box open.
2894
+ //
2895
+ // A hidden row is an addable the author has not put in the form yet, so it
2896
+ // is never what `preselected` is talking about, however its schema is
2897
+ // flagged.
2898
+ const isPreselectedRow = (entry: TRowEntry) =>
2899
+ !entry.hidden &&
2900
+ !!(options?.[entry.name] as { preselected?: boolean } | undefined)?.preselected;
2901
+ // ONLY the Optional box. A preselected field that HAS a value buckets to
2902
+ // 'set' and one that fails validation to 'attention'; in both boxes the
2903
+ // field is already accounted for, and whether those boxes start collapsed
2904
+ // is the consumer's deliberate `compactCollapsedGroups` choice. Overriding
2905
+ // them here forced the Set box open against an explicit request.
2906
+ const groupHasPreselectedRow = (boxKey: TFormEngineBoxKey, groupName: string) =>
2907
+ boxKey === 'optional' && (buckets[boxKey][groupName] || []).some(isPreselectedRow);
2908
+ const boxHasPreselectedRows = (boxKey: TFormEngineBoxKey) =>
2909
+ boxKey === 'optional' &&
2910
+ bucketGroups[boxKey].some((groupName) => groupHasPreselectedRow(boxKey, groupName));
2751
2911
  const STATUS_BOXES: Array<{
2752
- key: TBucketKey;
2912
+ key: TFormEngineBoxKey;
2753
2913
  label: string;
2754
2914
  intent?: 'warning' | 'success';
2755
2915
  icon: IReqoreIconName;
@@ -2836,6 +2996,52 @@ const FormEngineImpl = ({
2836
2996
  });
2837
2997
  };
2838
2998
 
2999
+ // One schema group inside a status box: its thin sub-label, the group's
3000
+ // subtitle, then its rows. Factored out of the box body so the SAME markup
3001
+ // renders both the groups a box shows directly and the ones it defers
3002
+ // behind the inner collapse.
3003
+ /** @param only when given, render just these rows of the group — the group
3004
+ can be split across the shown and folded halves of a capped box, and its
3005
+ label must appear above whichever half holds its first row. */
3006
+ const renderBoxGroup = (
3007
+ boxKey: TFormEngineBoxKey,
3008
+ groupName: string,
3009
+ only?: Set<string>
3010
+ ) => {
3011
+ const groupConfig = groups?.[groupName];
3012
+ const rows =
3013
+ only ?
3014
+ (buckets[boxKey][groupName] || []).filter((entry) => only.has(entry.name))
3015
+ : buckets[boxKey][groupName];
3016
+ if (only && !rows.length) {
3017
+ return null;
3018
+ }
3019
+ return (
3020
+ <React.Fragment key={groupName}>
3021
+ {showGroupSubLabel(groupName) ?
3022
+ <StyledStatusBoxGroupLabel>
3023
+ {getOptionGroupLabel(groupName, groups)}
3024
+ </StyledStatusBoxGroupLabel>
3025
+ : null}
3026
+ {showGroupSubLabel(groupName) && groupConfig?.subtitle ?
3027
+ <ReqoreP
3028
+ size='small'
3029
+ effect={{ opacity: 0.6 }}
3030
+ style={{
3031
+ marginTop: 2,
3032
+ marginBottom: 8,
3033
+ marginLeft: GROUP_INDENT,
3034
+ paddingRight: 10,
3035
+ }}
3036
+ >
3037
+ {groupConfig.subtitle}
3038
+ </ReqoreP>
3039
+ : null}
3040
+ {renderGroupRows(rows)}
3041
+ </React.Fragment>
3042
+ );
3043
+ };
3044
+
2839
3045
  return (
2840
3046
  <OptionsContext.Provider value={{ schema: options, value: availableOptions }}>
2841
3047
  <CompactRowContext.Provider value={compactRowContextValue}>
@@ -2855,6 +3061,10 @@ const FormEngineImpl = ({
2855
3061
  $flush={compactFlush || compactNested}
2856
3062
  >
2857
3063
  <StyledCompactPanel
3064
+ // Mirrors the toolbar's own search-row gate: when no search
3065
+ // row renders, the header is a thin strip and sits tight to
3066
+ // the first status box (see StyledCompactPanel).
3067
+ $tightHeader={!(compactToolbarParts?.search && size(availableOptions) > 1)}
2858
3068
  // The top-level form scrolls, so its toolbar STICKS and carries a
2859
3069
  // dark blurred backdrop so content ghosts cleanly beneath it. A
2860
3070
  // nested (arg_schema) sub-form owns no scroll context — drop the
@@ -2893,6 +3103,54 @@ const FormEngineImpl = ({
2893
3103
  const groupsInBox = bucketGroups[box.key];
2894
3104
  const count = bucketCount(box.key);
2895
3105
  if (!count) return null;
3106
+ const hasPreselectedRows = boxHasPreselectedRows(box.key);
3107
+ const wouldCollapse =
3108
+ compactCollapsedGroups.includes(box.key) &&
3109
+ !query &&
3110
+ !(box.key === 'optional' && onlyOptionalRows);
3111
+ // Only a box that was opened SOLELY to reveal a preselected
3112
+ // row splits its body; a box that renders open anyway shows
3113
+ // all of its groups as before.
3114
+ const splitForPreselected = wouldCollapse && hasPreselectedRows;
3115
+
3116
+ // Which rows this box shows, and which fold away, decided at
3117
+ // ROW level because `maxFieldsShown` counts fields — a
3118
+ // group-level split can only ever cut at a group boundary.
3119
+ // Group order is preserved so the visible rows read in the
3120
+ // same order they would if nothing were folded.
3121
+ const orderedRows: TRowEntry[] = groupsInBox.flatMap(
3122
+ (groupName) => buckets[box.key][groupName] || []
3123
+ );
3124
+ // Optional's own rule first: a box opened solely to reveal
3125
+ // preselected rows offers those, not an arbitrary first-N.
3126
+ const candidateRows =
3127
+ splitForPreselected ? orderedRows.filter(isPreselectedRow) : orderedRows;
3128
+ // …then the cap applies to whatever that left.
3129
+ const capped =
3130
+ maxFieldsShown !== undefined && candidateRows.length > maxFieldsShown;
3131
+ const shownRows = capped ? candidateRows.slice(0, maxFieldsShown) : candidateRows;
3132
+ const shownRowSet = new Set(shownRows.map((entry) => entry.name));
3133
+ // Everything the box holds that is not on show. Once the
3134
+ // reader asks for more they get the whole box — withholding
3135
+ // the non-preselected remainder behind a second control is
3136
+ // the same defect one level down.
3137
+ const deferredRowSet = new Set(
3138
+ orderedRows
3139
+ .filter((entry) => !shownRowSet.has(entry.name))
3140
+ .map((entry) => entry.name)
3141
+ );
3142
+ const deferredCount = deferredRowSet.size;
3143
+ const shownGroups = groupsInBox.filter((groupName) =>
3144
+ (buckets[box.key][groupName] || []).some((entry) =>
3145
+ shownRowSet.has(entry.name)
3146
+ )
3147
+ );
3148
+ const deferredGroups = groupsInBox.filter((groupName) =>
3149
+ (buckets[box.key][groupName] || []).some(
3150
+ (entry) => !shownRowSet.has(entry.name)
3151
+ )
3152
+ );
3153
+ const moreOpen = Boolean(query) || expandedMoreBoxes.includes(box.key);
2896
3154
  const accent =
2897
3155
  box.key === 'attention' ? cWarning
2898
3156
  : box.key === 'set' ? cSuccess
@@ -2921,24 +3179,33 @@ const FormEngineImpl = ({
2921
3179
  minimal
2922
3180
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
2923
3181
  collapsible
2924
- // The Optional box now holds every not-yet-added field, so
2925
- // it starts COLLAPSED to keep the form focused on what's in
2926
- // use. But a SEARCH must surface matching addable fields —
2927
- // and ReqorePanel unmounts collapsed content so force it
2928
- // open whenever a query is active. (isCollapsed is the
2929
- // panel's controllable state; manual toggling still works
2930
- // when no query is set.)
2931
- // The Optional box starts collapsed so a form opens on what
2932
- // is in use but only when there IS something else to
2933
- // open on. When every field is optional it is the whole
2934
- // form, and collapsing it renders a card that looks empty
2935
- // and broken: an auth profile's Authorization block, whose
2936
- // nine requirement fields are all optional, showed as a
2937
- // titled card containing one collapsed "Optional 9" strip
2938
- // and nothing else. A search still forces it open
2939
- // (ReqorePanel unmounts collapsed content, so a match
2940
- // inside it would be unreachable otherwise).
2941
- isCollapsed={box.key === 'optional' && !query && !onlyOptionalRows}
3182
+ // Which boxes start closed is the consumer's call
3183
+ // (`compactCollapsedGroups`, default `['optional']`: the
3184
+ // Optional box holds every not-yet-added field, so a form
3185
+ // opens on what is in use). Two rules override it:
3186
+ //
3187
+ // A SEARCH forces every box open — ReqorePanel unmounts
3188
+ // collapsed content, so a match inside a closed box would
3189
+ // be unreachable. (isCollapsed is the panel's controllable
3190
+ // state; manual toggling still works with no query set.)
3191
+ //
3192
+ // The Optional box stays open when it is the ONLY box,
3193
+ // because collapsing it renders a card that looks empty and
3194
+ // broken: an auth profile's Authorization block, whose nine
3195
+ // requirement fields are all optional, showed as a titled
3196
+ // card containing one collapsed "Optional 9" strip and
3197
+ // nothing else. This does not generalise to the other two —
3198
+ // a lone Set box collapsed is a deliberate ask, and its
3199
+ // header still reports what is in there.
3200
+ //
3201
+ // A PRESELECTED row overrides it too: the schema asked
3202
+ // for that field to be visible on a new form, so the
3203
+ // box it lands in opens. The groups it does NOT appear
3204
+ // in stay behind an inner collapse below, so opening
3205
+ // the box reveals the preselected fields without also
3206
+ // dumping every not-yet-added optional field on the
3207
+ // author.
3208
+ isCollapsed={wouldCollapse && !hasPreselectedRows}
2942
3209
  label={
2943
3210
  <StyledGroupHeader>
2944
3211
  <ReqoreP effect={{ weight: 'bold' }} size='normal'>
@@ -2969,35 +3236,51 @@ const FormEngineImpl = ({
2969
3236
  $success={cSuccess}
2970
3237
  $rowBg={cRowBg}
2971
3238
  $lineColor={cGroupLine}
3239
+ $moreBorder={cMoreBorder}
2972
3240
  className={compactNarrow ? 'readfirst-narrow' : undefined}
2973
3241
  >
2974
- {groupsInBox.map((groupName) => {
2975
- const groupConfig = groups?.[groupName];
2976
- return (
2977
- <React.Fragment key={groupName}>
2978
- {showGroupSubLabel(groupName) ?
2979
- <StyledStatusBoxGroupLabel>
2980
- {getOptionGroupLabel(groupName, groups)}
2981
- </StyledStatusBoxGroupLabel>
2982
- : null}
2983
- {showGroupSubLabel(groupName) && groupConfig?.subtitle ?
2984
- <ReqoreP
2985
- size='small'
2986
- effect={{ opacity: 0.6 }}
2987
- style={{
2988
- marginTop: 2,
2989
- marginBottom: 8,
2990
- marginLeft: GROUP_INDENT,
2991
- paddingRight: 10,
2992
- }}
2993
- >
2994
- {groupConfig.subtitle}
2995
- </ReqoreP>
2996
- : null}
2997
- {renderGroupRows(buckets[box.key][groupName])}
2998
- </React.Fragment>
2999
- );
3000
- })}
3242
+ {shownGroups.map((groupName) =>
3243
+ renderBoxGroup(box.key, groupName, shownRowSet)
3244
+ )}
3245
+ {/* The control lives INSIDE the group body, as the last
3246
+ row, so it inherits the rows' grid, padding and
3247
+ hover and lines up with them. Outside it was a bare
3248
+ line of text in the box's corner, which is what the
3249
+ build #154 review rejected. */}
3250
+ {deferredCount ?
3251
+ <button
3252
+ type='button'
3253
+ className='readfirst-row readfirst-more-row options-readfirst-more'
3254
+ aria-expanded={moreOpen}
3255
+ onClick={() =>
3256
+ setExpandedMoreBoxes((prev) =>
3257
+ prev.includes(box.key) ?
3258
+ prev.filter((k) => k !== box.key)
3259
+ : [...prev, box.key]
3260
+ )
3261
+ }
3262
+ >
3263
+ <ReqoreP size='small' effect={{ opacity: 0.8 }}>
3264
+ {moreOpen ?
3265
+ 'Show fewer'
3266
+ : `${deferredCount} more ${box.key === 'optional' ? 'optional ' : ''}field${deferredCount === 1 ? '' : 's'}`
3267
+ }
3268
+ </ReqoreP>
3269
+ <ReqoreIcon
3270
+ icon='ArrowDownSLine'
3271
+ size='small'
3272
+ className='readfirst-more-chevron'
3273
+ effect={{ opacity: 0.8 }}
3274
+ />
3275
+ </button>
3276
+ : null}
3277
+ {/* A search forces the fold open for the same reason
3278
+ the boxes open: an unmounted row cannot be found. */}
3279
+ {deferredCount && moreOpen ?
3280
+ deferredGroups.map((groupName) =>
3281
+ renderBoxGroup(box.key, groupName, deferredRowSet)
3282
+ )
3283
+ : null}
3001
3284
  </StyledGroupBody>
3002
3285
  </StyledStatusBox>
3003
3286
  );