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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.claude/CLAUDE.md +12 -0
  2. package/dist/components/codeSize/index.d.ts +37 -0
  3. package/dist/components/codeSize/index.d.ts.map +1 -0
  4. package/dist/components/codeSize/index.js +87 -0
  5. package/dist/components/codeSize/index.js.map +1 -0
  6. package/dist/components/form/engine/CompactRow.d.ts +20 -1
  7. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  8. package/dist/components/form/engine/CompactRow.js +365 -30
  9. package/dist/components/form/engine/CompactRow.js.map +1 -1
  10. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  11. package/dist/components/form/engine/CompactToolbar.js +6 -8
  12. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  13. package/dist/components/form/engine/FormEngine.d.ts +24 -45
  14. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  15. package/dist/components/form/engine/FormEngine.js +211 -84
  16. package/dist/components/form/engine/FormEngine.js.map +1 -1
  17. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts +37 -0
  18. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -0
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.js +335 -0
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -0
  21. package/dist/components/form/engine/compactRowStyles.d.ts +13 -0
  22. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  23. package/dist/components/form/engine/compactRowStyles.js +30 -5
  24. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  25. package/dist/components/form/engine/compactToolbarContext.d.ts +0 -8
  26. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  28. package/dist/components/form/engine/readFirst.d.ts +73 -1
  29. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  30. package/dist/components/form/engine/readFirst.js +175 -5
  31. package/dist/components/form/engine/readFirst.js.map +1 -1
  32. package/dist/components/form/fields/Field.d.ts.map +1 -1
  33. package/dist/components/form/fields/Field.js +1 -1
  34. package/dist/components/form/fields/Field.js.map +1 -1
  35. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  36. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  37. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  38. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  39. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  40. package/dist/components/form/fields/array/ArrayAuto.js +51 -21
  41. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  42. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  43. package/dist/components/form/fields/array/ArrayAutoField.js +42 -25
  44. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  45. package/dist/components/form/fields/auto/AutoFormField.d.ts +4 -0
  46. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  47. package/dist/components/form/fields/auto/AutoFormField.js +29 -4
  48. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  49. package/dist/components/form/fields/long-string/LongString.d.ts +9 -1
  50. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  51. package/dist/components/form/fields/long-string/LongString.js +25 -4
  52. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  53. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  54. package/dist/components/form/fields/template/TemplateField.js +12 -2
  55. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  56. package/dist/helpers/common.d.ts +8 -1
  57. package/dist/helpers/common.d.ts.map +1 -1
  58. package/dist/helpers/common.js +9 -1
  59. package/dist/helpers/common.js.map +1 -1
  60. package/dist/helpers/optionUiTypes.d.ts +30 -0
  61. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  62. package/dist/helpers/optionUiTypes.js +148 -0
  63. package/dist/helpers/optionUiTypes.js.map +1 -0
  64. package/dist/helpers/options.d.ts +19 -0
  65. package/dist/helpers/options.d.ts.map +1 -1
  66. package/dist/helpers/options.js +25 -1
  67. package/dist/helpers/options.js.map +1 -1
  68. package/dist/helpers/providerValue.d.ts +68 -0
  69. package/dist/helpers/providerValue.d.ts.map +1 -0
  70. package/dist/helpers/providerValue.js +50 -0
  71. package/dist/helpers/providerValue.js.map +1 -0
  72. package/dist/helpers/singleLineString.d.ts +37 -0
  73. package/dist/helpers/singleLineString.d.ts.map +1 -0
  74. package/dist/helpers/singleLineString.js +66 -0
  75. package/dist/helpers/singleLineString.js.map +1 -0
  76. package/dist/helpers/validations.d.ts +85 -2
  77. package/dist/helpers/validations.d.ts.map +1 -1
  78. package/dist/helpers/validations.js +802 -76
  79. package/dist/helpers/validations.js.map +1 -1
  80. package/dist/index.d.ts +1 -0
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +1 -0
  83. package/dist/index.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/components/codeSize/index.tsx +79 -0
  86. package/src/components/form/engine/CompactRow.tsx +450 -31
  87. package/src/components/form/engine/CompactToolbar.tsx +16 -21
  88. package/src/components/form/engine/FormEngine.stories.tsx +804 -17
  89. package/src/components/form/engine/FormEngine.tsx +207 -105
  90. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +233 -0
  91. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +630 -0
  92. package/src/components/form/engine/compactRowStyles.ts +44 -2
  93. package/src/components/form/engine/compactToolbarContext.ts +0 -8
  94. package/src/components/form/engine/readFirst.ts +201 -5
  95. package/src/components/form/fields/Field.tsx +3 -1
  96. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  97. package/src/components/form/fields/array/ArrayAuto.tsx +35 -4
  98. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +134 -0
  99. package/src/components/form/fields/array/ArrayAutoField.tsx +22 -4
  100. package/src/components/form/fields/auto/AutoFormField.tsx +30 -1
  101. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  102. package/src/components/form/fields/template/TemplateField.tsx +16 -1
  103. package/src/helpers/common.ts +11 -1
  104. package/src/helpers/optionUiTypes.ts +153 -0
  105. package/src/helpers/options.ts +23 -0
  106. package/src/helpers/providerValue.ts +125 -0
  107. package/src/helpers/singleLineString.ts +61 -0
  108. package/src/helpers/validations.ts +1105 -80
  109. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  110. package/src/index.tsx +1 -0
@@ -24,14 +24,6 @@ export interface ICompactToolbarOptionalField {
24
24
  */
25
25
  export interface ICompactToolbarContext {
26
26
  readOnly?: boolean;
27
- /** Which parts to render, already resolved from the engine's `compactToolbar`
28
- * prop — every key present, no undefined to re-default here. */
29
- parts: {
30
- completion: boolean;
31
- search: boolean;
32
- fields: boolean;
33
- help: boolean;
34
- };
35
27
  // Completion meter
36
28
  invalidCount: number;
37
29
  attentionCount: number;
@@ -1,4 +1,9 @@
1
- import { IQorusFormField, TQorusFormFieldSchema, TQorusType } from '@qoretechnologies/ts-toolkit';
1
+ import {
2
+ IQorusFormField,
3
+ IQorusFormSchema,
4
+ TQorusFormFieldSchema,
5
+ TQorusType,
6
+ } from '@qoretechnologies/ts-toolkit';
2
7
  import { isRendererOnlyUiType } from './rendererTypes';
3
8
  import { isUiEncodedValue } from './_structuredData/structuredData';
4
9
  import { renderExpressionToText } from '../expressions/renderExpressionToText';
@@ -40,6 +45,57 @@ const summarizeListItem = (item: unknown): string | number | undefined => {
40
45
  return undefined;
41
46
  };
42
47
 
48
+ /**
49
+ * Label ONE list item from the sub-schema that describes it.
50
+ *
51
+ * `summarizeListItem` can only read an item that names ITSELF — one carrying a
52
+ * `name` or `display_name` key. A hash whose fields are all domain fields names
53
+ * itself nowhere: an auth profile's scheme has `type`, `cookie_name`,
54
+ * `redirect_url`, and the row fell back to "2 items" — true, and silent about
55
+ * which two.
56
+ *
57
+ * The `arg_schema` is the same description the sub-form was built from, so the
58
+ * row can read an item the way the form asked for it: the FIRST declared field
59
+ * the item actually has, resolved through that field's allowed values. The
60
+ * scheme list then reads "Default RBAC, Cookie" — the words the author picked —
61
+ * instead of a count.
62
+ *
63
+ * Schema order, not value order: the first declared field is the one the form
64
+ * puts at the top of the item, which is the one that identifies it to its author.
65
+ * Value order is insertion order and would vary between two equal items.
66
+ */
67
+ const summarizeHashItemFromSchema = (
68
+ item: unknown,
69
+ argSchema: Record<string, TQorusFormFieldSchema> | undefined
70
+ ): string | number | undefined => {
71
+ if (!argSchema) {
72
+ return undefined;
73
+ }
74
+ const record = isTypedEnvelope(item) ? item.value : item;
75
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
76
+ return undefined;
77
+ }
78
+ const fields = record as Record<string, unknown>;
79
+ const key = Object.keys(argSchema).find((name) => {
80
+ const raw = fields[name];
81
+ const value = isTypedEnvelope(raw) ? raw.value : raw;
82
+ return value !== undefined && value !== null && value !== '';
83
+ });
84
+ if (!key) {
85
+ return undefined;
86
+ }
87
+ const raw = fields[key];
88
+ const value = isTypedEnvelope(raw) ? raw.value : raw;
89
+ const label = getAllowedValueLabel(value, argSchema[key]);
90
+ if (label) {
91
+ return label;
92
+ }
93
+ // No allowed values (a free-text field): the stored value IS what was typed,
94
+ // so it reads correctly as-is. An object at this position has no short form,
95
+ // and a count beats printing its shape.
96
+ return typeof value === 'string' || typeof value === 'number' ? value : undefined;
97
+ };
98
+
43
99
  /** Summarise a list value: join item labels, or fall back to an "N items" count
44
100
  * (e.g. a list of anonymous hashes). Never prints raw objects. */
45
101
  /**
@@ -52,8 +108,15 @@ const summarizeListItem = (item: unknown): string | number | undefined => {
52
108
  * does, so it can read the same way instead of printing the wire form back.
53
109
  */
54
110
  const formatList = (items: unknown[], schema?: TQorusFormFieldSchema): string => {
111
+ const argSchema = (schema as { arg_schema?: Record<string, TQorusFormFieldSchema> } | undefined)
112
+ ?.arg_schema;
55
113
  const parts = items
56
- .map((item) => getAllowedValueLabel(item, schema) ?? summarizeListItem(item))
114
+ .map(
115
+ (item) =>
116
+ getAllowedValueLabel(item, schema) ??
117
+ summarizeListItem(item) ??
118
+ summarizeHashItemFromSchema(item, argSchema)
119
+ )
57
120
  .filter((part) => part !== '' && part !== undefined && part !== null);
58
121
 
59
122
  return parts.length ? parts.join(', ') : pluralize(items.length, 'item');
@@ -133,7 +196,8 @@ export const shouldAutoCollapseCompactOption = (
133
196
  !isOptionValueEmpty(value) &&
134
197
  (isCompactBooleanOption(schema) || isFixedCompactAllowedValueOption(schema));
135
198
 
136
- /** Prefer the matching allowed_values entry's display_name (fallback `name`)
199
+ /**
200
+ * Prefer the matching allowed_values entry's display_name (fallback `name`)
137
201
  * over the raw stored value.
138
202
  *
139
203
  * A LIST carries its options under `element_allowed_values` — they constrain
@@ -141,8 +205,23 @@ export const shouldAutoCollapseCompactOption = (
141
205
  * printed what it stores rather than what you picked: `orders, batch` for
142
206
  * fields whose picker reads "Orders, Batch". The gap shows worst exactly where
143
207
  * allowed values earn their keep, since the stored form is often not readable
144
- * at all (a permission code, `PO_REQUIRE_TYPES`, an app-specific id). */
145
- const findAllowedOption = (value: unknown, schema?: TQorusFormFieldSchema): any | undefined => {
208
+ * at all (a permission code, `PO_REQUIRE_TYPES`, an app-specific id).
209
+ *
210
+ * Exported (as `findAllowedValueOption`) because the form engine's value
211
+ * validation has to answer the SAME question — "is this stored value one of the
212
+ * declared choices?" — and answering it differently loses data. An
213
+ * `allowed_values` entry is written three ways: an envelope (`{value: {type,
214
+ * value}}`), a bare value (`{value: 'default'}`) or a named entry
215
+ * (`{name: 'default'}`). This has always accepted all three; the engine's
216
+ * clearing guard accepted only the first and the third, so a schema using the
217
+ * bare form had its value ERASED on load while this function went on rendering
218
+ * the display name for it — the row read "Default RBAC" collapsed and "—" when
219
+ * opened, and the value was gone from the submitted data.
220
+ */
221
+ export const findAllowedValueOption = (
222
+ value: unknown,
223
+ schema?: TQorusFormFieldSchema
224
+ ): any | undefined => {
146
225
  const s = schema as
147
226
  | { allowed_values?: any[]; element_allowed_values?: any[]; items?: any[] }
148
227
  | undefined;
@@ -179,6 +258,9 @@ const getAllowedValueLabel = (
179
258
  return match ? match.display_name || match.title || match.name || undefined : undefined;
180
259
  };
181
260
 
261
+ /** Local alias for the module's own callers. */
262
+ const findAllowedOption = findAllowedValueOption;
263
+
182
264
  /** True when the field's selectable options carry images (logos). Such a choice
183
265
  * renders too tall/rich for an inline row, so compact opens it in the card. */
184
266
  export const optionHasImages = (schema?: TQorusFormFieldSchema): boolean => {
@@ -741,3 +823,117 @@ export const getFirstAttentionOptionName = (
741
823
 
742
824
  return undefined;
743
825
  };
826
+
827
+ /* ------------------------------------------------------------------------- */
828
+ /* Record identity — which field says WHICH item this is */
829
+ /* */
830
+ /* Lives here, beside the formatters it uses, rather than in the view that */
831
+ /* first needed it: it is data logic, not rendering, and BOTH list renderers */
832
+ /* plus their tests import it. Keeping it in `SchemaDataView` dragged React, */
833
+ /* styled-components and every Reqore component it renders into any module */
834
+ /* that only wanted to know an item's name — which broke `arrayAutoField`'s */
835
+ /* mocked-Reqore test the moment the editable list started using it. */
836
+ /* ------------------------------------------------------------------------- */
837
+
838
+ /** A UI-encoded value carries its type alongside it; the record's own value is inside. */
839
+ const unwrap = (value: unknown): unknown =>
840
+ isUiEncodedValue(value) ? (value as { value: unknown }).value : value;
841
+
842
+ /** Whether a value is worth a row of its own. `false` and `0` are values. */
843
+ export const isSet = (value: unknown): boolean =>
844
+ value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && !value.length);
845
+
846
+ /**
847
+ * The keys to render, in the order to render them: the schema's own order first
848
+ * (that is the order the form puts the fields in), then anything stored that the
849
+ * schema does not mention, so undescribed data is shown rather than dropped.
850
+ */
851
+ export const orderedKeys = (record: Record<string, unknown>, schema: IQorusFormSchema): string[] => {
852
+ const described = Object.keys(schema).filter((key) => isSet(unwrap(record[key])));
853
+ const extra = Object.keys(record).filter((key) => !(key in schema) && isSet(unwrap(record[key])));
854
+ return [...described, ...extra];
855
+ };
856
+
857
+ /** A field whose content is source code. `ui_type` is what the form renders by,
858
+ * so it is what the preview reads by too — the storage type is just `string`. */
859
+ export const isCodeField = (fieldSchema: TQorusFormFieldSchema | undefined): boolean => {
860
+ const uiType = (fieldSchema as { ui_type?: string } | undefined)?.ui_type;
861
+ return uiType === 'code-editor';
862
+ };
863
+
864
+ /**
865
+ * The field whose value heads the item — the first DECLARED one holding a plain
866
+ * scalar.
867
+ *
868
+ * Schema order, not value order: the first declared field is the one the form
869
+ * puts at the top of an item, which is the one that says which item it is. A
870
+ * code body or a nested level is skipped, not because it is unimportant but
871
+ * because it has no one-line form — it belongs in the rows below where it can
872
+ * actually be read.
873
+ *
874
+ * One definition, used both to RENDER the heading and to omit that field from
875
+ * the rows. Computing it twice is how the heading and the rows start disagreeing
876
+ * about which field was promoted, and the item shows its name twice or not at
877
+ * all.
878
+ */
879
+ export const titleKeyFor = (
880
+ record: Record<string, unknown>,
881
+ schema: IQorusFormSchema
882
+ ): string | undefined =>
883
+ orderedKeys(record, schema).find((key) => {
884
+ const fieldSchema = schema[key] as TQorusFormFieldSchema | undefined;
885
+ if (isCodeField(fieldSchema)) {
886
+ return false;
887
+ }
888
+ const raw = unwrap(record[key]);
889
+ return typeof raw === 'string' || typeof raw === 'number';
890
+ });
891
+
892
+ /** What identifies one record: which field was promoted, and how to draw it. */
893
+ export interface IRecordIdentity {
894
+ /** The promoted field's key, so a caller can omit it from the rows below. */
895
+ key: string;
896
+ /** The value, formatted the way its own row would format it. */
897
+ text: string;
898
+ /** The promoted field's label — a caption for the heading, not a second line. */
899
+ label: string;
900
+ /** A literal keeps its mono face; a chosen label is prose. */
901
+ mono: boolean;
902
+ }
903
+
904
+ /**
905
+ * The identity of one record, for any surface that heads a list item with it.
906
+ *
907
+ * Exported because the preview is not the only place a list of records is shown:
908
+ * the editable list (`ArrayAuto`) heads the same records, and it must promote the
909
+ * same field and format it the same way. Two implementations would drift the
910
+ * moment one of them learned about a new field type, and the reader would meet
911
+ * an item called `init` in the preview and `#1` in the editor.
912
+ */
913
+ export const recordIdentity = (
914
+ record: Record<string, unknown>,
915
+ schema: IQorusFormSchema | undefined
916
+ ): IRecordIdentity | undefined => {
917
+ if (!schema) {
918
+ return undefined;
919
+ }
920
+
921
+ const key = titleKeyFor(record, schema);
922
+
923
+ if (!key) {
924
+ return undefined;
925
+ }
926
+
927
+ const fieldSchema = schema[key] as TQorusFormFieldSchema | undefined;
928
+ const raw = unwrap(record[key]);
929
+
930
+ return {
931
+ key,
932
+ text: formatOptionValue({ type: fieldSchema?.type, value: raw } as IQorusFormField, fieldSchema),
933
+ label: fieldLabel(key, fieldSchema),
934
+ mono: !findAllowedValueOption(raw, fieldSchema),
935
+ };
936
+ };
937
+
938
+ export const fieldLabel = (key: string, fieldSchema: TQorusFormFieldSchema | undefined): string =>
939
+ (fieldSchema as { display_name?: string } | undefined)?.display_name || key;
@@ -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
  }
@@ -24,6 +24,7 @@ import { validateFieldWithResult } from '../../../../helpers/validations';
24
24
  import { useWhyDidYouUpdate } from '../../../../hooks/useWhyDidYouUpdate';
25
25
  // Direct imports — the cycle (ArrayAuto → TemplateField/auto → ArrayAuto) is
26
26
  // safe the same way Field → AutoFormField → Field is.
27
+ import { recordIdentity } from '../../engine/readFirst';
27
28
  import { AutoFormField } from '../auto/AutoFormField';
28
29
  import { TemplateField } from '../template/TemplateField';
29
30
 
@@ -64,6 +65,12 @@ export const ArrayAuto = ({
64
65
  }: IArrayAutoProps): any => {
65
66
  const confirmAction = useReqoreProperty('confirmAction');
66
67
  const [localValue, setLocalValue] = useState(value);
68
+ // The row this component just created, so its sub-form can open the field it
69
+ // cannot be saved without. Only a row added HERE gets it: the rows already in
70
+ // the value were added by an earlier session (or arrived from the server), and
71
+ // opening one of those on mount would be this component reopening a decision
72
+ // the author already made.
73
+ const [justAddedIndex, setJustAddedIndex] = useState<number | undefined>(undefined);
67
74
 
68
75
  useWhyDidYouUpdate(`Array Auto ${name}`, {
69
76
  name,
@@ -87,6 +94,7 @@ export const ArrayAuto = ({
87
94
  );
88
95
 
89
96
  const addValue: () => void = () => {
97
+ setJustAddedIndex(localValue.length);
90
98
  setLocalValue([...localValue, defaultValueByType[rest.type]]);
91
99
  };
92
100
 
@@ -126,12 +134,29 @@ export const ArrayAuto = ({
126
134
  // Render list of auto fields
127
135
  return (
128
136
  <ReqoreControlGroup vertical fluid>
129
- {map(localValue, (val: string | number, idx: string) => (
137
+ {map(localValue, (val: string | number, idx: string) => {
138
+ // The same field the collapsed preview promotes, resolved by the same
139
+ // definition — see `recordIdentity`. A list of seven methods headed
140
+ // `#1 … #7` makes the reader open each one to find out which is which,
141
+ // and heading them differently in the preview and the editor would make
142
+ // one item answer to two names.
143
+ const identity =
144
+ val && typeof val === 'object' && !Array.isArray(val)
145
+ ? recordIdentity(val as Record<string, unknown>, rest.arg_schema)
146
+ : undefined;
147
+
148
+ return (
130
149
  <ReqorePanel
131
150
  key={idx}
132
- label={`#${idx + 1}`}
151
+ // `#N` stays as the fallback: a row whose identifying field is still
152
+ // empty (one just added) has nothing to be called yet, and a blank
153
+ // heading would be worse than a number.
154
+ label={identity?.text || `#${idx + 1}`}
155
+ tooltip={identity?.label}
133
156
  unMountContentOnCollapse={false}
134
- badge={display_name || name}
157
+ // The position moves to the badge once the name owns the heading, so
158
+ // "which of these is third" stays answerable.
159
+ badge={identity ? `#${idx + 1}` : display_name || name}
135
160
  collapsible
136
161
  responsiveActions={false}
137
162
  responsiveTitle={false}
@@ -171,12 +196,18 @@ export const ArrayAuto = ({
171
196
  allowTemplates
172
197
  defaultType={rest.type}
173
198
  name={`${name}-${idx}`}
199
+ // AFTER `{...rest}` on purpose: this is the one prop that differs
200
+ // per row, and spreading rest over it would give every row the same
201
+ // answer. Rides TemplateField's `rest` into the row's AutoFormField,
202
+ // which hands it to the row's nested FormEngine (case 'hash').
203
+ expandFirstRequired={Number(idx) === justAddedIndex}
174
204
  value={val}
175
205
  onChange={(_name, value) => handleChange(idx, value)}
176
206
  />
177
207
  {showValidationMessage(val)}
178
208
  </ReqorePanel>
179
- ))}
209
+ );
210
+ })}
180
211
  <ReqoreControlGroup fluid>
181
212
  <ReqoreButton
182
213
  onClick={addValue}
@@ -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: {
@@ -339,3 +392,84 @@ export const ComplexHashItems: Story = {
339
392
  await expect(document.querySelectorAll('.array-auto-compact-tag').length).toBe(0);
340
393
  },
341
394
  };
395
+
396
+ /**
397
+ * The editable list, headed by what identifies each record.
398
+ *
399
+ * The rows used to read `#1 #2 #3`, so finding one method meant opening each in
400
+ * turn — and the collapsed preview beside it already headed the same records
401
+ * `init` / `onOrderStatus`, which made one item answer to two names. Both now
402
+ * resolve the heading through `recordIdentity`: the first field the SCHEMA
403
+ * declares that holds a scalar, skipping any it leaves unset. The position keeps
404
+ * a badge, so "the third one" is still answerable.
405
+ */
406
+ export const HashItemsHeadedByIdentity: Story = {
407
+ args: {
408
+ type: 'hash',
409
+ display_name: 'Service Methods',
410
+ arg_schema: {
411
+ name: { type: 'string', ui_type: 'string', display_name: 'Method Name', required: true },
412
+ description: { type: 'string', ui_type: 'string', display_name: 'Description' },
413
+ },
414
+ value: [
415
+ { name: 'init', description: 'The init method' },
416
+ { name: 'onOrderStatus', description: 'Handles order status callbacks' },
417
+ { description: 'Added but not yet named' },
418
+ {},
419
+ ],
420
+ renderItem: hashRenderItem,
421
+ },
422
+ parameters: {
423
+ docs: {
424
+ description: {
425
+ story:
426
+ 'Renders four hash items headed by their identifying value: two named methods read as "init" and "onOrderStatus", one with no name falls through to its description, and an entirely empty one keeps its position number.',
427
+ },
428
+ },
429
+ },
430
+ async play() {
431
+ await waitFor(() => expect(document.querySelectorAll('.array-auto-item').length).toBe(4), {
432
+ timeout: 5000,
433
+ });
434
+
435
+ const headings = [...document.querySelectorAll('.array-auto-item')].map(
436
+ (item) => item.textContent ?? ''
437
+ );
438
+
439
+ await expect(headings[0]).toContain('init');
440
+ await expect(headings[1]).toContain('onOrderStatus');
441
+ // An unset field is skipped rather than promoted blank, so this one is
442
+ // headed by the next field that IS set.
443
+ await expect(headings[2]).toContain('Added but not yet named');
444
+ // And a record with nothing set at all keeps its position — there is
445
+ // genuinely nothing to call it.
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]');
474
+ },
475
+ };
@@ -9,6 +9,7 @@ import {
9
9
  } from '@qoretechnologies/reqore';
10
10
  import { IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
11
11
  import { map, size } from 'lodash';
12
+ import { recordIdentity } from '../../engine/readFirst';
12
13
  import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
13
14
  import { useDebounce } from 'react-use';
14
15
  import { validateFieldWithResult } from '../../../../helpers/validations';
@@ -279,11 +280,27 @@ export const ArrayAutoField = memo(
279
280
 
280
281
  return (
281
282
  <ReqoreControlGroup vertical fluid>
282
- {map(localValue, (val, idx) => (
283
+ {map(localValue, (val, idx) => {
284
+ // The same field the collapsed preview promotes, resolved by the same
285
+ // definition — see `recordIdentity`. Both editable list renderers head
286
+ // their records with it, so an item cannot be called `init` in one
287
+ // place and `#1` in another.
288
+ const identity =
289
+ val && typeof val === 'object' && !Array.isArray(val)
290
+ ? recordIdentity(val as Record<string, unknown>, arg_schema)
291
+ : undefined;
292
+
293
+ return (
283
294
  <ReqorePanel
284
295
  key={idx}
285
- label={`#${idx + 1}`}
286
- badge={display_name || name}
296
+ // `#N` stays as the fallback: a record whose identifying field is
297
+ // still empty has nothing to be called yet, and a blank heading
298
+ // would be worse than a number.
299
+ label={identity?.text || `#${idx + 1}`}
300
+ tooltip={identity?.label}
301
+ // The position moves to the badge once the name owns the heading,
302
+ // so "which of these is third" stays answerable.
303
+ badge={identity ? `#${idx + 1}` : display_name || name}
287
304
  collapsible
288
305
  unMountContentOnCollapse={false}
289
306
  responsiveActions={false}
@@ -321,7 +338,8 @@ export const ArrayAutoField = memo(
321
338
  })}
322
339
  {renderValidationMessage(val)}
323
340
  </ReqorePanel>
324
- ))}
341
+ );
342
+ })}
325
343
 
326
344
  <ReqoreButton
327
345
  onClick={addItem}