@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
@@ -5,7 +5,7 @@ import {
5
5
  TQorusType,
6
6
  } from '@qoretechnologies/ts-toolkit';
7
7
  import { isRendererOnlyUiType } from './rendererTypes';
8
- import { isUiEncodedValue } from './_structuredData/structuredData';
8
+ import { isEmptyUiEnvelope, isUiEncodedValue } from './_structuredData/structuredData';
9
9
  import { renderExpressionToText } from '../expressions/renderExpressionToText';
10
10
  import { IExpressionValue } from '../expressions/types';
11
11
  import yaml from 'js-yaml';
@@ -155,6 +155,45 @@ export const isOptionValueEmpty = (value: unknown): boolean =>
155
155
  value === '' ||
156
156
  (Array.isArray(value) && value.length === 0);
157
157
 
158
+ /**
159
+ * A schema-declared hash whose every entry is a materialised-but-unset field.
160
+ *
161
+ * `preselected` on a sub-field means "show this in the form so the author sees
162
+ * it exists" — a FORM instruction. Opening the sub-form materialises each one,
163
+ * `fixOptions` writes `{type: 'int'}` with no value, and the draft autosaves
164
+ * that, so the stored hash permanently carries keys that stand for nothing.
165
+ *
166
+ * The row then read those keys as content: `Runtime Defaults` listed three
167
+ * sub-fields with no values, counted toward "7/12 set", and showed a green
168
+ * set-dot — while the sub-form it summarises correctly said 0/3 set. Nothing
169
+ * was ever entered.
170
+ *
171
+ * Requires the sub-schema, and requires each key to be declared by it: without
172
+ * that, `{type: 'x'}` could be somebody's data rather than an empty envelope.
173
+ */
174
+ export const isUnsetSchemaHash = (value: unknown, schema?: TQorusFormFieldSchema): boolean => {
175
+ const argSchema = (schema as { arg_schema?: Record<string, unknown> } | undefined)?.arg_schema;
176
+ if (!argSchema || typeof argSchema !== 'object') {
177
+ return false;
178
+ }
179
+ const inner =
180
+ value && typeof value === 'object' && !Array.isArray(value) && 'value' in (value as object) ?
181
+ (value as { value: unknown }).value
182
+ : value;
183
+ if (!inner || typeof inner !== 'object' || Array.isArray(inner)) {
184
+ return false;
185
+ }
186
+ const keys = Object.keys(inner as object);
187
+ return (
188
+ keys.length > 0 &&
189
+ keys.every(
190
+ (key) =>
191
+ !!(argSchema as Record<string, unknown>)[key] &&
192
+ isEmptyUiEnvelope((inner as Record<string, unknown>)[key])
193
+ )
194
+ );
195
+ };
196
+
158
197
  export const isFixedCompactAllowedValueOption = (
159
198
  schema: TQorusFormFieldSchema | undefined
160
199
  ): boolean => {
@@ -200,6 +239,13 @@ export const shouldAutoCollapseCompactOption = (
200
239
  * Prefer the matching allowed_values entry's display_name (fallback `name`)
201
240
  * over the raw stored value.
202
241
  *
242
+ * A LIST carries its options under `element_allowed_values` — they constrain
243
+ * each element, not the list itself — so a multi-select that was missing here
244
+ * printed what it stores rather than what you picked: `orders, batch` for
245
+ * fields whose picker reads "Orders, Batch". The gap shows worst exactly where
246
+ * allowed values earn their keep, since the stored form is often not readable
247
+ * at all (a permission code, `PO_REQUIRE_TYPES`, an app-specific id).
248
+ *
203
249
  * Exported (as `findAllowedValueOption`) because the form engine's value
204
250
  * validation has to answer the SAME question — "is this stored value one of the
205
251
  * declared choices?" — and answering it differently loses data. An
@@ -215,13 +261,30 @@ export const findAllowedValueOption = (
215
261
  value: unknown,
216
262
  schema?: TQorusFormFieldSchema
217
263
  ): any | undefined => {
218
- const s = schema as { allowed_values?: any[]; items?: any[] } | undefined;
219
- const options = s?.allowed_values?.length ? s.allowed_values : s?.items;
264
+ const s = schema as
265
+ { allowed_values?: any[]; element_allowed_values?: any[]; items?: any[] } | undefined;
266
+ const options =
267
+ (s?.allowed_values?.length && s.allowed_values) ||
268
+ (s?.element_allowed_values?.length && s.element_allowed_values) ||
269
+ s?.items;
220
270
  if (!options?.length) {
221
271
  return undefined;
222
272
  }
273
+ // A stored element can be the bare value or the typed `{type, value}`
274
+ // envelope the form engine round-trips; match either against either.
275
+ const stored =
276
+ value && typeof value === 'object' && 'value' in (value as Record<string, unknown>) ?
277
+ (value as Record<string, unknown>).value
278
+ : value;
279
+
223
280
  return options.find(
224
- (option) => option?.value?.value === value || option?.value === value || option?.name === value
281
+ (option) =>
282
+ option?.value?.value === value ||
283
+ option?.value === value ||
284
+ option?.name === value ||
285
+ option?.value?.value === stored ||
286
+ option?.value === stored ||
287
+ option?.name === stored
225
288
  );
226
289
  };
227
290
 
@@ -499,7 +562,7 @@ export const formatOptionValue = (
499
562
  ): string => {
500
563
  const value = option?.value;
501
564
 
502
- if (isOptionValueEmpty(value)) {
565
+ if (isOptionValueEmpty(value) || isUnsetSchemaHash(value, schema)) {
503
566
  return '';
504
567
  }
505
568
 
@@ -641,34 +704,55 @@ export const getHashEntries = (
641
704
  const argSchema = (schema as { arg_schema?: Record<string, TQorusFormFieldSchema> } | undefined)
642
705
  ?.arg_schema;
643
706
 
644
- return Object.keys(hashObject).map((key) => {
645
- const raw = hashObject![key];
646
- const subSchema = argSchema?.[key];
647
- // A schema-declared (`arg_schema`) hash stores each entry as a
648
- // `{ type, value }` field descriptor gate on the sub-schema's presence.
649
- // A schema-less hash *usually* stores raw values, but the server's typed
650
- // serialization can nest envelopes there too (e.g. a hash default_value
651
- // whose entries are `{ type: 'string', value: 'x' }`) — recognise those by
652
- // their strict shape (a string `type`, a `value`, and no foreign keys) so
653
- // the sub-row shows 'x' rather than counting the envelope's own keys.
654
- const isFieldShape =
655
- (!!subSchema &&
656
- !!raw &&
657
- typeof raw === 'object' &&
658
- !Array.isArray(raw) &&
659
- 'value' in (raw as object)) ||
660
- isTypedEnvelope(raw);
661
- const subOption: IQorusFormField =
662
- isFieldShape ?
663
- (raw as IQorusFormField)
664
- : ({ type: subSchema?.type as IQorusFormField['type'], value: raw } as IQorusFormField);
665
-
666
- return {
667
- name: key,
668
- label: subSchema?.display_name || key,
669
- value: formatOptionValue(subOption, subSchema),
670
- };
671
- });
707
+ return Object.keys(hashObject)
708
+ .filter((key) => {
709
+ // Only sub-fields with a value belong in a read view. `preselected`
710
+ // materialises a field so the FORM shows it; that is not a statement
711
+ // that the object has one, and listing it here says the opposite of
712
+ // what the sub-form says.
713
+ const subSchema = argSchema?.[key];
714
+ return !(subSchema && isEmptyUiEnvelope(hashObject![key]));
715
+ })
716
+ .map((key) => {
717
+ const raw = hashObject![key];
718
+ const subSchema = argSchema?.[key];
719
+ // A schema-declared (`arg_schema`) hash stores each entry as a
720
+ // `{ type, value }` field descriptor — gate on the sub-schema's presence.
721
+ // A schema-less hash *usually* stores raw values, but the server's typed
722
+ // serialization can nest envelopes there too (e.g. a hash default_value
723
+ // whose entries are `{ type: 'string', value: 'x' }`) — recognise those by
724
+ // their strict shape (a string `type`, a `value`, and no foreign keys) so
725
+ // the sub-row shows 'x' rather than counting the envelope's own keys.
726
+ // `'value' in raw` is FALSE for a materialised-but-unset field: `fixOptions`
727
+ // omits the key entirely when the value is undefined, leaving `{type: 'x'}`.
728
+ // Requiring `value` therefore classed that envelope as a raw hash VALUE and
729
+ // counted its own `type` key, so an untouched Runtime Defaults collapsed to
730
+ // "Timeout (Seconds) 1 field" three times over while the sub-form correctly
731
+ // said 0/3 set. With a sub-schema present, a `type`-only object is an empty
732
+ // envelope, never content.
733
+ // Shared with SchemaDataView, which previews this same value one level
734
+ // down: two renderers disagreeing about what an unset field looks like is
735
+ // how the row said "1 field" long after this one stopped.
736
+ const isEmptyEnvelope = !!subSchema && isEmptyUiEnvelope(raw);
737
+ const isFieldShape =
738
+ (!!subSchema &&
739
+ !!raw &&
740
+ typeof raw === 'object' &&
741
+ !Array.isArray(raw) &&
742
+ 'value' in (raw as object)) ||
743
+ isEmptyEnvelope ||
744
+ isTypedEnvelope(raw);
745
+ const subOption: IQorusFormField =
746
+ isFieldShape ?
747
+ (raw as IQorusFormField)
748
+ : ({ type: subSchema?.type as IQorusFormField['type'], value: raw } as IQorusFormField);
749
+
750
+ return {
751
+ name: key,
752
+ label: subSchema?.display_name || key,
753
+ value: formatOptionValue(subOption, subSchema),
754
+ };
755
+ });
672
756
  };
673
757
 
674
758
  const titleCase = (value: string): string =>
@@ -823,7 +907,10 @@ export const isSet = (value: unknown): boolean =>
823
907
  * (that is the order the form puts the fields in), then anything stored that the
824
908
  * schema does not mention, so undescribed data is shown rather than dropped.
825
909
  */
826
- export const orderedKeys = (record: Record<string, unknown>, schema: IQorusFormSchema): string[] => {
910
+ export const orderedKeys = (
911
+ record: Record<string, unknown>,
912
+ schema: IQorusFormSchema
913
+ ): string[] => {
827
914
  const described = Object.keys(schema).filter((key) => isSet(unwrap(record[key])));
828
915
  const extra = Object.keys(record).filter((key) => !(key in schema) && isSet(unwrap(record[key])));
829
916
  return [...described, ...extra];
@@ -904,7 +991,10 @@ export const recordIdentity = (
904
991
 
905
992
  return {
906
993
  key,
907
- text: formatOptionValue({ type: fieldSchema?.type, value: raw } as IQorusFormField, fieldSchema),
994
+ text: formatOptionValue(
995
+ { type: fieldSchema?.type, value: raw } as IQorusFormField,
996
+ fieldSchema
997
+ ),
908
998
  label: fieldLabel(key, fieldSchema),
909
999
  mono: !findAllowedValueOption(raw, fieldSchema),
910
1000
  };
@@ -569,7 +569,9 @@ export const FormField = <T extends TFormFieldType>({
569
569
  return (
570
570
  <ReqoreControlGroup vertical size='tiny'>
571
571
  <ReqoreSpan effect={{ opacity: 0.6, uppercase: true, weight: 'bold' }} size='tiny'>
572
- Select one:
572
+ {/* Headed by the field's own name; "Select one:" only when it has
573
+ none. Same rule as FieldAllowedValuesCheckGroup. */}
574
+ {display_name ? `${display_name}:` : 'Select one:'}
573
575
  </ReqoreSpan>
574
576
  {allowed_values.map((item, index) => {
575
577
  const itemValue = item.value?.value;
@@ -43,15 +43,28 @@ export const FieldAllowedValuesCheckGroup = memo(
43
43
  onChange,
44
44
  name,
45
45
  value,
46
+ label,
46
47
  ...rest
47
48
  }: Partial<Omit<IFieldAllowedValuesProps, 'items'>> & {
48
49
  multiSelect?: boolean;
49
50
  items: ISelectFormFieldItem[];
51
+ /**
52
+ * The field's own name, used as the group's heading.
53
+ *
54
+ * A picker headed by what it IS beats one headed by a generic instruction:
55
+ * "Select one:" says nothing the shape of a radio group has not already
56
+ * said, and in a container that absorbs a sibling it forced the field's name
57
+ * to be printed a second time beside the group. Falls back to the
58
+ * instruction when a field declares no display name.
59
+ */
60
+ label?: string;
50
61
  }) => {
51
62
  return (
52
63
  <ReqoreControlGroup vertical size={rest.size || 'small'} gapSize='tiny'>
53
64
  <ReqoreSpan effect={{ opacity: 0.6, uppercase: true, weight: 'bold' }} size='tiny'>
54
- {multiSelect ? 'Select one or more:' : 'Select one:'}
65
+ {label ? `${label}:`
66
+ : multiSelect ? 'Select one or more:'
67
+ : 'Select one:'}
55
68
  </ReqoreSpan>
56
69
  {items?.map((item) => (
57
70
  <ReqoreCheckbox
@@ -62,10 +75,17 @@ export const FieldAllowedValuesCheckGroup = memo(
62
75
  disabled={rest.disabled}
63
76
  readOnly={rest.readOnly}
64
77
  intent={item.value === value ? 'info' : undefined}
78
+ // `isEqual`, and only once there is a value to match. The single
79
+ // -select branch compared JSON.stringify(value) with
80
+ // JSON.stringify(item.value), and JSON.stringify(undefined) is
81
+ // undefined — so an unset field whose items carry no resolved value
82
+ // compared undefined with undefined and reported EVERY option as
83
+ // checked, on a field that was simultaneously "This field is
84
+ // required". Nothing selected must read as nothing selected.
65
85
  checked={
66
86
  multiSelect
67
87
  ? Array.isArray(value) && value.some((v) => isEqual(v, item.value))
68
- : JSON.stringify(value) === JSON.stringify(item.value)
88
+ : value !== undefined && value !== null && isEqual(value, item.value)
69
89
  }
70
90
  onClick={() => {
71
91
  if (multiSelect) {
@@ -103,6 +123,7 @@ export const FieldAllowedValues = memo(
103
123
  disabled,
104
124
  name,
105
125
  readOnly,
126
+ label,
106
127
  }: IFieldAllowedValuesProps) => {
107
128
  const fullItems = useMemo(() => {
108
129
  const result = [
@@ -131,6 +152,7 @@ export const FieldAllowedValues = memo(
131
152
  onChange={onChange}
132
153
  value={value}
133
154
  name={name}
155
+ label={label}
134
156
  />
135
157
  );
136
158
  }
@@ -46,6 +46,58 @@ const numberRenderItem: IArrayAutoFieldProps['renderItem'] = ({
46
46
  />
47
47
  );
48
48
 
49
+ /**
50
+ * Hash items are records, so a renderer that casts the item to a string prints
51
+ * `[object Object]` and shows nothing at all. This one reads the record through
52
+ * the schema that describes it: one input per declared field, labelled by that
53
+ * field's `display_name`, carrying the value actually stored under its key.
54
+ *
55
+ * With no `arg_schema` there is nothing to name the parts with, so the record is
56
+ * shown as JSON — unhelpful, but at least it is the data.
57
+ */
58
+ const hashRenderItem: IArrayAutoFieldProps['renderItem'] = ({
59
+ value,
60
+ onChange,
61
+ disabled,
62
+ readOnly,
63
+ size,
64
+ arg_schema,
65
+ }) => {
66
+ const record = (value ?? {}) as Record<string, unknown>;
67
+ const fields = Object.entries(arg_schema ?? {});
68
+
69
+ if (!fields.length) {
70
+ return (
71
+ <StringFormField
72
+ value={JSON.stringify(record)}
73
+ onChange={() => {}}
74
+ disabled={disabled}
75
+ readOnly
76
+ size={size as any}
77
+ aria-label='Array item input'
78
+ />
79
+ );
80
+ }
81
+
82
+ return (
83
+ <ReqoreControlGroup vertical fluid gapSize='small'>
84
+ {fields.map(([fieldName, fieldSchema]) => (
85
+ <StringFormField
86
+ key={fieldName}
87
+ value={(record[fieldName] as string) ?? ''}
88
+ onChange={(v) => onChange({ ...record, [fieldName]: v })}
89
+ disabled={disabled}
90
+ readOnly={readOnly}
91
+ size={size as any}
92
+ aria-label={
93
+ ((fieldSchema as { display_name?: string })?.display_name as string) ?? fieldName
94
+ }
95
+ />
96
+ ))}
97
+ </ReqoreControlGroup>
98
+ );
99
+ };
100
+
49
101
  const meta = {
50
102
  component: ArrayAutoField,
51
103
  title: 'Components/Form/Auto Array',
@@ -322,6 +374,7 @@ export const ComplexHashItems: Story = {
322
374
  type: 'hash',
323
375
  value: [{ key: 'value' }],
324
376
  display_name: 'Hash Items',
377
+ renderItem: hashRenderItem,
325
378
  },
326
379
  parameters: {
327
380
  docs: {
@@ -364,6 +417,7 @@ export const HashItemsHeadedByIdentity: Story = {
364
417
  { description: 'Added but not yet named' },
365
418
  {},
366
419
  ],
420
+ renderItem: hashRenderItem,
367
421
  },
368
422
  parameters: {
369
423
  docs: {
@@ -390,5 +444,32 @@ export const HashItemsHeadedByIdentity: Story = {
390
444
  // And a record with nothing set at all keeps its position — there is
391
445
  // genuinely nothing to call it.
392
446
  await expect(headings[3]).toContain('#4');
447
+
448
+ // The heading is only half the story: each item must also render the record
449
+ // it is heading, field by field. Asserting the inputs is what would have
450
+ // caught them all printing "[object Object]" — the heading resolved
451
+ // correctly while every value below it was a stringified hash.
452
+ const nameInputs = document.querySelectorAll<HTMLInputElement>(
453
+ 'input[aria-label="Method Name"]'
454
+ );
455
+ const descInputs = document.querySelectorAll<HTMLInputElement>(
456
+ 'input[aria-label="Description"]'
457
+ );
458
+
459
+ await expect(nameInputs.length).toBe(4);
460
+ await expect(descInputs.length).toBe(4);
461
+ await expect(nameInputs[0].value).toBe('init');
462
+ await expect(descInputs[0].value).toBe('The init method');
463
+ await expect(nameInputs[1].value).toBe('onOrderStatus');
464
+ // The unnamed record: no name to show, but the description it IS headed by
465
+ // has to be there.
466
+ await expect(nameInputs[2].value).toBe('');
467
+ await expect(descInputs[2].value).toBe('Added but not yet named');
468
+ // And the empty one renders empty inputs rather than an empty item.
469
+ await expect(nameInputs[3].value).toBe('');
470
+ await expect(descInputs[3].value).toBe('');
471
+
472
+ // Nothing anywhere may read as a stringified record.
473
+ await expect(document.body.textContent).not.toContain('[object Object]');
393
474
  },
394
475
  };
@@ -11,7 +11,7 @@
11
11
  import { StoryObj } from '@storybook/react-vite';
12
12
  import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
13
13
  import jsyaml from 'js-yaml';
14
- import { useState } from 'react';
14
+ import { useEffect, useState } from 'react';
15
15
 
16
16
  import { StoryMeta } from '../../../../types';
17
17
  import { buildTemplates } from '../../../../helpers/templates';
@@ -512,3 +512,49 @@ export const ListWithElementType: Story = {
512
512
  },
513
513
  },
514
514
  };
515
+
516
+ /**
517
+ * A named `arg_schema` whose fetch fails.
518
+ *
519
+ * The field asks the server for `dataprovider/arg_schemas/<name>`. When that
520
+ * request rejected — a malformed instance URL, an aborted request, a transport
521
+ * error — the rejection escaped the effect's async IIFE unhandled, `setError`
522
+ * never ran, and `finalArgSchema` stayed undefined. The loading branch then
523
+ * returned on every subsequent render and the error branch below it was
524
+ * unreachable, so the field showed "Loading field data..." for the life of the
525
+ * form: a request that failed instantly and one that is merely slow looked
526
+ * exactly the same, forever.
527
+ *
528
+ * The transport is stubbed rather than left to the dev server, so the story
529
+ * exercises that path and only that path.
530
+ */
531
+ export const NamedArgSchemaFetchFails: Story = {
532
+ args: {
533
+ name: 'rules',
534
+ type: 'list',
535
+ element_type: 'hash',
536
+ arg_schema: 'a-schema-that-does-not-resolve' as any,
537
+ },
538
+ parameters: {
539
+ docs: {
540
+ description: {
541
+ story:
542
+ 'The named arg schema could not be fetched, so the field says so instead of showing an endless "Loading field data..." spinner with the error it had already received never rendered.',
543
+ },
544
+ },
545
+ },
546
+ decorators: [
547
+ (Story) => {
548
+ const real = window.fetch;
549
+ window.fetch = (() =>
550
+ Promise.reject(new Error('Failed to fetch the field schema'))) as typeof window.fetch;
551
+ useEffect(() => () => {
552
+ window.fetch = real;
553
+ }, []);
554
+ return <Story />;
555
+ },
556
+ ],
557
+ play: async () => {
558
+ await waitForText(/Failed to fetch the field schema|could not be loaded/);
559
+ },
560
+ };
@@ -18,6 +18,7 @@ import React, { memo, useCallback, useEffect, useState } from 'react';
18
18
  import { useUpdateEffect } from 'react-use';
19
19
  import useMount from 'react-use/lib/useMount';
20
20
  import { typedToYaml, yamlToTyped } from '../../../../helpers/common';
21
+ import { getListElementValue } from '../../../../helpers/options';
21
22
  import {
22
23
  getTypeFromValue,
23
24
  getValueOrDefaultValue,
@@ -212,16 +213,31 @@ function AutoField<T = any>({
212
213
  useEffect(() => {
213
214
  if (typeof arg_schema === 'string') {
214
215
  (async () => {
215
- const schema = await query<IOptionsSchema>({
216
- url: `dataprovider/arg_schemas/${arg_schema}`,
217
- method: 'GET',
218
- });
219
-
220
- if (schema.ok) {
221
- setError(undefined);
222
- setFinalArgSchema(schema.data);
223
- } else {
224
- setError(schema.error);
216
+ /* The await is guarded because `query` can REJECT rather than answer
217
+ `{ ok: false }` — a malformed instance URL, an aborted request, a
218
+ transport error. Unguarded, that rejection escaped the async IIFE as
219
+ an unhandled promise rejection, `setError` never ran, `finalArgSchema`
220
+ stayed undefined, and the field showed "Loading field data..." for the
221
+ life of the form: a request that failed instantly and a request that
222
+ is merely slow looked exactly the same, forever. */
223
+ try {
224
+ const schema = await query<IOptionsSchema>({
225
+ url: `dataprovider/arg_schemas/${arg_schema}`,
226
+ method: 'GET',
227
+ });
228
+
229
+ if (schema.ok) {
230
+ setError(undefined);
231
+ setFinalArgSchema(schema.data);
232
+ } else {
233
+ setError(schema.error ?? `The schema "${arg_schema}" could not be loaded.`);
234
+ }
235
+ } catch (fetchError) {
236
+ setError(
237
+ fetchError instanceof Error ?
238
+ fetchError.message
239
+ : `The schema "${arg_schema}" could not be loaded.`
240
+ );
225
241
  }
226
242
  })();
227
243
  } else {
@@ -363,6 +379,18 @@ function AutoField<T = any>({
363
379
  return (
364
380
  <Override
365
381
  {...rest}
382
+ // The field's SHAPE, which `rest` does not carry: these three are
383
+ // destructured into named props above, so a host editor received a
384
+ // field it could not describe. `arg_schema` is the sub-schema a host
385
+ // sub-form renders FROM — without it the IDE's test-cases drawer fell
386
+ // back to humanising each key, so `Path`, `Actual` and `Expected` were
387
+ // asked for with no label of their own and no description, while the
388
+ // text sat one fetch away. `element_type`/`ui_element_type` travel with
389
+ // it: a sub-schema alone cannot say whether the value is one hash or a
390
+ // list of them.
391
+ arg_schema={arg_schema}
392
+ element_type={element_type}
393
+ ui_element_type={ui_element_type}
366
394
  name={name}
367
395
  value={value}
368
396
  onChange={(val: any, emittedType?: IQorusType, emittedIsFunction?: boolean) => {
@@ -407,6 +435,23 @@ function AutoField<T = any>({
407
435
  );
408
436
  }
409
437
 
438
+ /* The error is checked BEFORE the spinner, and the spinner waits on the error
439
+ being absent.
440
+
441
+ A named `arg_schema` is fetched (`dataprovider/arg_schemas/<name>`), and when
442
+ that fetch failed `finalArgSchema` stayed undefined forever — so this spinner
443
+ returned on every subsequent render and the `if (error)` branch further down
444
+ was unreachable. The field sat on "Loading field data..." for the life of the
445
+ form with no way to find out why, which is indistinguishable from a request
446
+ that is merely slow. */
447
+ if (arg_schema && !finalArgSchema && error) {
448
+ return (
449
+ <ReqoreMessage intent='danger' size='small'>
450
+ {error}
451
+ </ReqoreMessage>
452
+ );
453
+ }
454
+
410
455
  if (arg_schema && !finalArgSchema) {
411
456
  return <ReqoreSpinner size='small'>Loading field data...</ReqoreSpinner>;
412
457
  }
@@ -424,6 +469,9 @@ function AutoField<T = any>({
424
469
  value={value}
425
470
  name={name}
426
471
  onChange={handleChange}
472
+ // The picker heads itself with the field's own name rather than a
473
+ // generic "Select one:".
474
+ label={rest.display_name}
427
475
  size={rest.size}
428
476
  disabled={rest.disabled}
429
477
  app={rest.app}
@@ -507,6 +555,12 @@ function AutoField<T = any>({
507
555
  return (
508
556
  <LongStringFormField
509
557
  {...rest}
558
+ // These four share one editor but not one shape: a `string` holds
559
+ // exactly one line, while `data`, `binary` and `long-string` hold
560
+ // a document. Without the type the field cannot tell them apart
561
+ // and treats them all as documents, so an interface's Internal
562
+ // Name — which becomes a YAML key — accepted Enter.
563
+ type={currentType}
510
564
  onChange={(value) => handleChange(name, value)}
511
565
  value={value}
512
566
  />
@@ -644,8 +698,16 @@ function AutoField<T = any>({
644
698
  });
645
699
  };
646
700
 
701
+ // An element may arrive bare or in a `{value, type}` envelope. The
702
+ // envelope is what `formatToServerValue` writes, so a list the user
703
+ // has edited in this session is always wrapped — but a list read
704
+ // back from storage holds exactly what the server contract asks
705
+ // for, which for a list of strings is the strings themselves.
706
+ // Reaching for `.value` on those answered `undefined` for every
707
+ // element: the row rendered as an empty, invalid item, and the
708
+ // emptiness was then written back over the stored value.
647
709
  const formatFromServerValue = (value) => {
648
- return (value || []).map((item) => item?.value);
710
+ return (value || []).map(getListElementValue);
649
711
  };
650
712
 
651
713
  const mappedAllowedValues = rest.element_allowed_values?.map((ev) => ({