@qoretechnologies/reqraft 0.10.36 → 0.10.38-pr.95.gf1f6403

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 (35) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +121 -2
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/FormEngine.d.ts +20 -0
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +91 -22
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts +27 -0
  9. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -0
  10. package/dist/components/form/engine/_structuredData/SchemaDataView.js +259 -0
  11. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -0
  12. package/dist/components/form/engine/compactRowStyles.d.ts +11 -0
  13. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  14. package/dist/components/form/engine/compactRowStyles.js +14 -3
  15. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  16. package/dist/components/form/engine/readFirst.d.ts +16 -0
  17. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  18. package/dist/components/form/engine/readFirst.js +72 -5
  19. package/dist/components/form/engine/readFirst.js.map +1 -1
  20. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  21. package/dist/components/form/fields/array/ArrayAuto.js +13 -1
  22. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  23. package/dist/components/form/fields/auto/AutoFormField.d.ts +4 -0
  24. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  25. package/dist/components/form/fields/auto/AutoFormField.js +8 -1
  26. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/form/engine/CompactRow.tsx +112 -11
  29. package/src/components/form/engine/FormEngine.stories.tsx +313 -13
  30. package/src/components/form/engine/FormEngine.tsx +98 -7
  31. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +462 -0
  32. package/src/components/form/engine/compactRowStyles.ts +14 -2
  33. package/src/components/form/engine/readFirst.ts +81 -4
  34. package/src/components/form/fields/array/ArrayAuto.tsx +12 -0
  35. package/src/components/form/fields/auto/AutoFormField.tsx +11 -0
@@ -0,0 +1,462 @@
1
+ import { ReqoreCollapsibleContent, ReqoreIcon, ReqoreP, ReqoreTag } from '@qoretechnologies/reqore';
2
+ import {
3
+ IQorusFormField,
4
+ IQorusFormSchema,
5
+ TQorusFormFieldSchema,
6
+ } from '@qoretechnologies/ts-toolkit';
7
+ import React from 'react';
8
+ import styled from 'styled-components';
9
+ import { useContextSelector } from 'use-context-selector';
10
+ import { CompactRowContext } from '../compactRowContext';
11
+ import { MONO_FONT_STACK, StyledCodePreview, StyledRowLabel, StyledRowValue } from '../compactRowStyles';
12
+ import { findAllowedValueOption, formatOptionValue, getAllowedValueImage } from '../readFirst';
13
+ import { isUiEncodedValue } from './structuredData';
14
+
15
+ /**
16
+ * A read-only rendering of a value THROUGH the schema that describes it.
17
+ *
18
+ * `StructuredDataView` renders data nobody has described: it has to announce
19
+ * what it found ("Object · 2 fields"), print raw keys, and colour values by
20
+ * inferred type, because inference is all it has. That is the right rendering
21
+ * for untyped data and the wrong one for ours — a form field with an
22
+ * `arg_schema` is a value whose shape, names and choices are all known in
23
+ * advance, and showing it as a data tree asks the reader to decode a structure
24
+ * the form already knows how to say out loud.
25
+ *
26
+ * So a described value is rendered the way the form asked for it, in the form's
27
+ * own clothes:
28
+ *
29
+ * - Field names use `StyledRowLabel` and values use `StyledRowValue` — the very
30
+ * components the option rows above are built from. Not a copy of their CSS: a
31
+ * copy is a second definition of "what a form label looks like", and the two
32
+ * drift the first time either is touched.
33
+ * - Values are formatted by the same `formatOptionValue` the rows use, so an
34
+ * `allowed_values` entry resolves to its display name, a bool to Yes/No, a
35
+ * date to the app's format. The same words appear in the summary, the preview
36
+ * and the editor.
37
+ * - Fields appear in the schema's order — the order the form puts them in.
38
+ *
39
+ * A list renders as numbered items: the number sits in a left margin with a rule
40
+ * running down beside the fields belonging to it, and the item's first field
41
+ * starts on the number's own line. The rule is what makes a list of multi-field
42
+ * items readable — without it, "which item does this field belong to?" is
43
+ * answered only by counting.
44
+ *
45
+ * Two deliberate properties:
46
+ *
47
+ * - **Nothing is hidden.** A key the schema does not describe is still shown,
48
+ * under its raw name, after the described ones. A preview that quietly dropped
49
+ * data would be worse than a raw tree, not better.
50
+ * - **It degrades, it does not fail.** With no schema for a level, the caller
51
+ * keeps using `StructuredDataView` — undescribed data still deserves the
52
+ * renderer built for undescribed data.
53
+ */
54
+
55
+ /** Width of the shared label column, set once on the root (see `labelColumn`). */
56
+ const LABEL_COL_VAR = '--schema-view-label-col';
57
+
58
+ const StyledSchemaView = styled.div`
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: 6px;
62
+ min-width: 0;
63
+ `;
64
+
65
+ /**
66
+ * One list item: its number in the left margin, its fields beside it.
67
+ *
68
+ * `align-items: start` over a single grid row is what puts the first field on
69
+ * the number's line — the fields are one block starting at the top, not a grid
70
+ * row each, so nothing can push them down past their own marker.
71
+ */
72
+ const StyledItem = styled.div`
73
+ display: grid;
74
+ grid-template-columns: auto minmax(0, 1fr);
75
+ column-gap: 8px;
76
+ align-items: start;
77
+ min-width: 0;
78
+ `;
79
+
80
+ /** The item number. Furniture, so it reads quieter than the data it indexes;
81
+ * `tabular-nums` keeps "9." and "10." the same width. */
82
+ const StyledMarker = styled.div<{ $color: string }>`
83
+ color: ${({ $color }) => $color};
84
+ font-size: 12px;
85
+ font-variant-numeric: tabular-nums;
86
+ line-height: 20px;
87
+ white-space: nowrap;
88
+ `;
89
+
90
+ /** The rule tying an item's fields to its number. */
91
+ const StyledItemFields = styled.div<{ $border: string }>`
92
+ border-left: 1px solid ${({ $border }) => $border};
93
+ padding-left: 10px;
94
+ min-width: 0;
95
+ `;
96
+
97
+ /**
98
+ * A record's fields: one shared label column, values in the next.
99
+ *
100
+ * The column width is a variable set once on the root, so every field of every
101
+ * item lines up on one edge. Sizing per record would align an item's own fields
102
+ * and leave the items ragged against each other, which reads as a rendering
103
+ * fault rather than as a list.
104
+ */
105
+ const StyledFields = styled.div`
106
+ display: grid;
107
+ grid-template-columns: var(${LABEL_COL_VAR}, 140px) minmax(0, 1fr);
108
+ column-gap: 10px;
109
+ row-gap: 2px;
110
+ align-items: baseline;
111
+ min-width: 0;
112
+
113
+ /* A row's value truncates because a row is one line high. The preview is the
114
+ place that shows what the row could not, so a long value wraps here instead
115
+ of ellipsising — otherwise opening the preview would reveal exactly as
116
+ little as the line above it. */
117
+ .options-readfirst-valuetext {
118
+ white-space: normal;
119
+ overflow: visible;
120
+ text-overflow: clip;
121
+ overflow-wrap: anywhere;
122
+ }
123
+
124
+ /* Mono faces run visually larger than the UI face at the same pixel size, so
125
+ a literal set at the row's 13px looks a step bolder than the label naming
126
+ it. A hair smaller puts the two back on the same footing. */
127
+ .schema-view-data {
128
+ font-size: 12.5px;
129
+ }
130
+ `;
131
+
132
+ /** A nested level spans both columns and indents under its own name. */
133
+ const StyledNested = styled.div<{ $border: string }>`
134
+ grid-column: 1 / -1;
135
+ margin-left: 2px;
136
+ padding-left: 10px;
137
+ border-left: 1px solid ${({ $border }) => $border};
138
+ min-width: 0;
139
+ `;
140
+
141
+ const StyledNestedLabel = styled.div`
142
+ grid-column: 1 / -1;
143
+ `;
144
+
145
+ /** A code value is a block, not a cell: it spans the grid under its own name. */
146
+ const StyledCodeCell = styled.div`
147
+ grid-column: 1 / -1;
148
+ min-width: 0;
149
+ margin: 2px 0 4px;
150
+ `;
151
+
152
+ export interface ISchemaDataViewProps {
153
+ /** The stored value, with or without `{type, value}` envelopes. */
154
+ value: unknown;
155
+ /** The schema describing this level — a field's `arg_schema`. */
156
+ schema: IQorusFormSchema;
157
+ /** Show each field's declared type beside its name. */
158
+ showTypes?: boolean;
159
+ /** Colours, taken from the row so the preview matches its surroundings. */
160
+ colors: { key: string; muted: string; border: string };
161
+ onItemClick?: () => void;
162
+ }
163
+
164
+ /** Look inside a `{type, value}` envelope, once. `isUiEncodedValue` is the
165
+ * engine's single authority on what an envelope is — a looser test would unwrap
166
+ * a real user hash that happens to carry a `value` key and drop its siblings. */
167
+ const unwrap = (item: unknown): unknown => (isUiEncodedValue(item) ? item.value : item);
168
+
169
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
170
+ !!value && typeof value === 'object' && !Array.isArray(value);
171
+
172
+ /** Whether a value is worth a row of its own. `false` and `0` are values. */
173
+ const isSet = (value: unknown): boolean =>
174
+ value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && !value.length);
175
+
176
+ /**
177
+ * The keys to render, in the order to render them: the schema's own order first
178
+ * (that is the order the form puts the fields in), then anything stored that the
179
+ * schema does not mention, so undescribed data is shown rather than dropped.
180
+ */
181
+ const orderedKeys = (record: Record<string, unknown>, schema: IQorusFormSchema): string[] => {
182
+ const described = Object.keys(schema).filter((key) => isSet(unwrap(record[key])));
183
+ const extra = Object.keys(record).filter((key) => !(key in schema) && isSet(unwrap(record[key])));
184
+ return [...described, ...extra];
185
+ };
186
+
187
+ /** A field whose content is source code. `ui_type` is what the form renders by,
188
+ * so it is what the preview reads by too — the storage type is just `string`. */
189
+ const isCodeField = (fieldSchema: TQorusFormFieldSchema | undefined): boolean => {
190
+ const uiType = (fieldSchema as { ui_type?: string } | undefined)?.ui_type;
191
+ return uiType === 'code-editor';
192
+ };
193
+
194
+ const fieldLabel = (key: string, fieldSchema: TQorusFormFieldSchema | undefined): string =>
195
+ (fieldSchema as { display_name?: string } | undefined)?.display_name || key;
196
+
197
+ /**
198
+ * The width of the shared label column, measured from the longest name that will
199
+ * actually be rendered.
200
+ *
201
+ * A fixed width is a guess in both directions: too wide and a name sits far from
202
+ * its value, too narrow and every name ellipsises. Measuring fits the content;
203
+ * the `%` ceiling stops one long name from taking the whole row when the inset
204
+ * is narrow — a phone, or a deeply nested level.
205
+ */
206
+ const labelColumn = (value: unknown, schema: IQorusFormSchema): string => {
207
+ const inner = unwrap(value);
208
+ const records = (Array.isArray(inner) ? inner.map(unwrap) : [inner]).filter(isRecord);
209
+ const longest = records.reduce((widest, record) => {
210
+ const own = orderedKeys(record, schema).reduce(
211
+ (max, key) =>
212
+ Math.max(max, fieldLabel(key, schema[key] as TQorusFormFieldSchema | undefined).length),
213
+ 0
214
+ );
215
+ return Math.max(widest, own);
216
+ }, 0);
217
+ return longest ? `min(${longest + 1}ch, 45%)` : '140px';
218
+ };
219
+
220
+ /** One field's value, rendered the way its row above renders it. */
221
+ const FieldValue = ({
222
+ field,
223
+ fieldSchema,
224
+ color,
225
+ }: {
226
+ field: IQorusFormField;
227
+ fieldSchema: TQorusFormFieldSchema | undefined;
228
+ color: string;
229
+ }) => {
230
+ const formatted = formatOptionValue(field, fieldSchema);
231
+ // An allowed value that carries a logo shows it here too, exactly as the row
232
+ // above does — the preview is the same value, so it gets the same treatment.
233
+ const image = getAllowedValueImage(field.value, fieldSchema);
234
+
235
+ // Data reads in mono; a chosen label does not.
236
+ //
237
+ // "qorus-session" is a literal the author typed — an identifier where the
238
+ // characters themselves matter, and where a proportional font makes `l`/`1`
239
+ // and `O`/`0` a guess. "Default RBAC" is not data at all: it is the name of a
240
+ // choice, written by whoever wrote the schema, and setting it in mono would
241
+ // dress up a label as a value. Resolving through `allowed_values` is what
242
+ // separates the two, and it is the same lookup that produced the text.
243
+ const isChosenLabel = !!findAllowedValueOption(field.value, fieldSchema);
244
+
245
+ return (
246
+ <StyledRowValue $color={color}>
247
+ {field.value === true || field.value === false ?
248
+ <ReqoreTag
249
+ size='small'
250
+ minimal
251
+ intent={field.value === true ? 'success' : 'danger'}
252
+ label={formatted}
253
+ />
254
+ : <span
255
+ className={`options-readfirst-valuetext${isChosenLabel ? '' : ' schema-view-data'}`}
256
+ style={isChosenLabel ? undefined : { fontFamily: MONO_FONT_STACK }}
257
+ >
258
+ {image ?
259
+ <ReqoreIcon
260
+ image={image}
261
+ size='14px'
262
+ style={{ flexShrink: 0, marginRight: 6, verticalAlign: 'middle' }}
263
+ />
264
+ : null}
265
+ {formatted}
266
+ </span>
267
+ }
268
+ </StyledRowValue>
269
+ );
270
+ };
271
+
272
+ /** One described hash: its fields as label/value pairs in the shared columns. */
273
+ const SchemaRecord = ({
274
+ record,
275
+ schema,
276
+ showTypes,
277
+ colors,
278
+ }: {
279
+ record: Record<string, unknown>;
280
+ schema: IQorusFormSchema;
281
+ showTypes?: boolean;
282
+ colors: ISchemaDataViewProps['colors'];
283
+ }) => {
284
+ // The SAME host renderer the row above uses for a `code-editor` field. Read
285
+ // from context rather than threaded through props: a nested level is arbitrarily
286
+ // deep, and the renderer is a property of the form, not of any one level.
287
+ const codePreviewRenderer = useContextSelector(
288
+ CompactRowContext,
289
+ (v) => v.codePreviewRenderer
290
+ );
291
+ const cHover = useContextSelector(CompactRowContext, (v) => v.cHover);
292
+
293
+ return (
294
+ <StyledFields className='schema-view-fields'>
295
+ {orderedKeys(record, schema).map((key) => {
296
+ const fieldSchema = schema[key] as TQorusFormFieldSchema | undefined;
297
+ const nested = (fieldSchema as { arg_schema?: IQorusFormSchema } | undefined)?.arg_schema;
298
+ const value = unwrap(record[key]);
299
+ const label = fieldLabel(key, fieldSchema);
300
+ const labelWithType =
301
+ showTypes && fieldSchema?.type ? `${label} · ${fieldSchema.type}` : label;
302
+
303
+ // A field that describes its own level renders AS that level, indented
304
+ // under its name — the same decision, one step down.
305
+ if (nested && (isRecord(value) || Array.isArray(value))) {
306
+ return (
307
+ <React.Fragment key={key}>
308
+ <StyledNestedLabel>
309
+ <StyledRowLabel $color={colors.key}>{labelWithType}</StyledRowLabel>
310
+ </StyledNestedLabel>
311
+ <StyledNested $border={colors.border}>
312
+ <SchemaLevel value={value} schema={nested} showTypes={showTypes} colors={colors} />
313
+ </StyledNested>
314
+ </React.Fragment>
315
+ );
316
+ }
317
+
318
+ // A code value is source, and source is read as source: the host's renderer
319
+ // gives it the syntax highlighting (and read-only LSP hover) it has in the
320
+ // Source Code field, and "Show more" keeps a long body from taking over the
321
+ // preview. Flattening it to one mono line — which is what a plain value cell
322
+ // does — throws all of that away for a field whose whole content is code.
323
+ if (isCodeField(fieldSchema) && typeof value === 'string') {
324
+ return (
325
+ <React.Fragment key={key}>
326
+ <StyledNestedLabel>
327
+ <StyledRowLabel $color={colors.key}>{labelWithType}</StyledRowLabel>
328
+ </StyledNestedLabel>
329
+ <StyledCodeCell onClick={(event) => event.stopPropagation()}>
330
+ <ReqoreCollapsibleContent
331
+ maxCollapsedHeight={96}
332
+ buttonProps={{ className: 'options-readfirst-viewmore' }}
333
+ >
334
+ {codePreviewRenderer ?
335
+ codePreviewRenderer({
336
+ value,
337
+ name: key,
338
+ schema: fieldSchema,
339
+ options: schema,
340
+ values: record as never,
341
+ })
342
+ : <StyledCodePreview
343
+ className='options-readfirst-code'
344
+ $bg={cHover}
345
+ $border={`${colors.border}88`}
346
+ $fg={colors.key}
347
+ >
348
+ {value}
349
+ </StyledCodePreview>
350
+ }
351
+ </ReqoreCollapsibleContent>
352
+ </StyledCodeCell>
353
+ </React.Fragment>
354
+ );
355
+ }
356
+
357
+ return (
358
+ <React.Fragment key={key}>
359
+ <StyledRowLabel $color={colors.key} title={label}>
360
+ {labelWithType}
361
+ </StyledRowLabel>
362
+ <FieldValue
363
+ field={{ type: fieldSchema?.type, value } as IQorusFormField}
364
+ fieldSchema={fieldSchema}
365
+ color={colors.key}
366
+ />
367
+ </React.Fragment>
368
+ );
369
+ })}
370
+ </StyledFields>
371
+ );
372
+ };
373
+
374
+ /** A level: a list of numbered items, or a single described hash. */
375
+ const SchemaLevel = ({
376
+ value,
377
+ schema,
378
+ showTypes,
379
+ colors,
380
+ }: {
381
+ value: unknown;
382
+ schema: IQorusFormSchema;
383
+ showTypes?: boolean;
384
+ colors: ISchemaDataViewProps['colors'];
385
+ }) => {
386
+ const inner = unwrap(value);
387
+
388
+ if (Array.isArray(inner)) {
389
+ return (
390
+ <>
391
+ {inner.map((item, index) => {
392
+ const record = unwrap(item);
393
+ return (
394
+ <StyledItem key={index} className='schema-view-item'>
395
+ <StyledMarker $color={colors.muted}>{index + 1}.</StyledMarker>
396
+ <StyledItemFields $border={colors.border}>
397
+ {isRecord(record) ?
398
+ <SchemaRecord
399
+ record={record}
400
+ schema={schema}
401
+ showTypes={showTypes}
402
+ colors={colors}
403
+ />
404
+ : <StyledRowValue $color={colors.key}>
405
+ <span
406
+ className='options-readfirst-valuetext schema-view-data'
407
+ style={{ fontFamily: MONO_FONT_STACK }}
408
+ >
409
+ {formatOptionValue({ value: record } as IQorusFormField)}
410
+ </span>
411
+ </StyledRowValue>
412
+ }
413
+ </StyledItemFields>
414
+ </StyledItem>
415
+ );
416
+ })}
417
+ </>
418
+ );
419
+ }
420
+
421
+ if (isRecord(inner)) {
422
+ return <SchemaRecord record={inner} schema={schema} showTypes={showTypes} colors={colors} />;
423
+ }
424
+
425
+ return (
426
+ <ReqoreP size='small' style={{ color: colors.key }}>
427
+ {formatOptionValue({ value: inner } as IQorusFormField)}
428
+ </ReqoreP>
429
+ );
430
+ };
431
+
432
+ export const SchemaDataView = React.memo(
433
+ ({ value, schema, showTypes, colors, onItemClick }: ISchemaDataViewProps) => (
434
+ <StyledSchemaView
435
+ className='schema-data-view'
436
+ onClick={onItemClick}
437
+ style={{ [LABEL_COL_VAR]: labelColumn(value, schema) } as React.CSSProperties}
438
+ >
439
+ <SchemaLevel value={value} schema={schema} showTypes={showTypes} colors={colors} />
440
+ </StyledSchemaView>
441
+ )
442
+ );
443
+
444
+ SchemaDataView.displayName = 'SchemaDataView';
445
+
446
+ /**
447
+ * Whether a value can be rendered through a schema at all.
448
+ *
449
+ * A schema describes fields, so there has to be a described level to show: a
450
+ * hash, or a list of them. A list of scalars has no field names to use and falls
451
+ * back to the untyped renderer, which is the right tool for it.
452
+ */
453
+ export const canRenderWithSchema = (value: unknown, schema: unknown): boolean => {
454
+ if (!schema || typeof schema !== 'object' || !Object.keys(schema).length) {
455
+ return false;
456
+ }
457
+ const inner = unwrap(value);
458
+ if (Array.isArray(inner)) {
459
+ return inner.some((item) => isRecord(unwrap(item)));
460
+ }
461
+ return isRecord(inner);
462
+ };
@@ -381,6 +381,19 @@ export const StyledRowInset = styled.div`
381
381
  margin-top: 4px;
382
382
  `;
383
383
 
384
+ /**
385
+ * The stack literal text renders in: source, ids, tokens, data strings.
386
+ *
387
+ * Defined HERE rather than taken from reqore's `ReqoreFonts.mono`, which is the
388
+ * same stack. Reqore is a peer dependency at `>=0.71.16` and that export arrived
389
+ * later, so reading it would crash every consumer inside the declared range —
390
+ * the whole form preview lost over a font choice. One local constant costs
391
+ * nothing and keeps the code preview and the schema view's data values
392
+ * identical, which is the only reason to share it in the first place.
393
+ */
394
+ export const MONO_FONT_STACK =
395
+ "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace";
396
+
384
397
  // A collapsed code-block preview shown under the value summary for a
385
398
  // `code-editor` field. Multi-line, monospace, subtle background — matches
386
399
  // the aesthetic of the classic code-view surface but small enough to sit
@@ -393,8 +406,7 @@ export const StyledCodePreview = styled.pre<{ $bg: string; $border: string; $fg:
393
406
  background: ${({ $bg }) => $bg};
394
407
  border: 1px solid ${({ $border }) => $border};
395
408
  color: ${({ $fg }) => $fg};
396
- font-family:
397
- ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
409
+ font-family: ${MONO_FONT_STACK};
398
410
  font-size: 11.5px;
399
411
  line-height: 1.5;
400
412
  white-space: pre;
@@ -40,6 +40,57 @@ const summarizeListItem = (item: unknown): string | number | undefined => {
40
40
  return undefined;
41
41
  };
42
42
 
43
+ /**
44
+ * Label ONE list item from the sub-schema that describes it.
45
+ *
46
+ * `summarizeListItem` can only read an item that names ITSELF — one carrying a
47
+ * `name` or `display_name` key. A hash whose fields are all domain fields names
48
+ * itself nowhere: an auth profile's scheme has `type`, `cookie_name`,
49
+ * `redirect_url`, and the row fell back to "2 items" — true, and silent about
50
+ * which two.
51
+ *
52
+ * The `arg_schema` is the same description the sub-form was built from, so the
53
+ * row can read an item the way the form asked for it: the FIRST declared field
54
+ * the item actually has, resolved through that field's allowed values. The
55
+ * scheme list then reads "Default RBAC, Cookie" — the words the author picked —
56
+ * instead of a count.
57
+ *
58
+ * Schema order, not value order: the first declared field is the one the form
59
+ * puts at the top of the item, which is the one that identifies it to its author.
60
+ * Value order is insertion order and would vary between two equal items.
61
+ */
62
+ const summarizeHashItemFromSchema = (
63
+ item: unknown,
64
+ argSchema: Record<string, TQorusFormFieldSchema> | undefined
65
+ ): string | number | undefined => {
66
+ if (!argSchema) {
67
+ return undefined;
68
+ }
69
+ const record = isTypedEnvelope(item) ? item.value : item;
70
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
71
+ return undefined;
72
+ }
73
+ const fields = record as Record<string, unknown>;
74
+ const key = Object.keys(argSchema).find((name) => {
75
+ const raw = fields[name];
76
+ const value = isTypedEnvelope(raw) ? raw.value : raw;
77
+ return value !== undefined && value !== null && value !== '';
78
+ });
79
+ if (!key) {
80
+ return undefined;
81
+ }
82
+ const raw = fields[key];
83
+ const value = isTypedEnvelope(raw) ? raw.value : raw;
84
+ const label = getAllowedValueLabel(value, argSchema[key]);
85
+ if (label) {
86
+ return label;
87
+ }
88
+ // No allowed values (a free-text field): the stored value IS what was typed,
89
+ // so it reads correctly as-is. An object at this position has no short form,
90
+ // and a count beats printing its shape.
91
+ return typeof value === 'string' || typeof value === 'number' ? value : undefined;
92
+ };
93
+
43
94
  /** Summarise a list value: join item labels, or fall back to an "N items" count
44
95
  * (e.g. a list of anonymous hashes). Never prints raw objects. */
45
96
  /**
@@ -52,8 +103,15 @@ const summarizeListItem = (item: unknown): string | number | undefined => {
52
103
  * does, so it can read the same way instead of printing the wire form back.
53
104
  */
54
105
  const formatList = (items: unknown[], schema?: TQorusFormFieldSchema): string => {
106
+ const argSchema = (schema as { arg_schema?: Record<string, TQorusFormFieldSchema> } | undefined)
107
+ ?.arg_schema;
55
108
  const parts = items
56
- .map((item) => getAllowedValueLabel(item, schema) ?? summarizeListItem(item))
109
+ .map(
110
+ (item) =>
111
+ getAllowedValueLabel(item, schema) ??
112
+ summarizeListItem(item) ??
113
+ summarizeHashItemFromSchema(item, argSchema)
114
+ )
57
115
  .filter((part) => part !== '' && part !== undefined && part !== null);
58
116
 
59
117
  return parts.length ? parts.join(', ') : pluralize(items.length, 'item');
@@ -133,9 +191,25 @@ export const shouldAutoCollapseCompactOption = (
133
191
  !isOptionValueEmpty(value) &&
134
192
  (isCompactBooleanOption(schema) || isFixedCompactAllowedValueOption(schema));
135
193
 
136
- /** Prefer the matching allowed_values entry's display_name (fallback `name`)
137
- * over the raw stored value. */
138
- const findAllowedOption = (value: unknown, schema?: TQorusFormFieldSchema): any | undefined => {
194
+ /**
195
+ * Prefer the matching allowed_values entry's display_name (fallback `name`)
196
+ * over the raw stored value.
197
+ *
198
+ * Exported (as `findAllowedValueOption`) because the form engine's value
199
+ * validation has to answer the SAME question — "is this stored value one of the
200
+ * declared choices?" — and answering it differently loses data. An
201
+ * `allowed_values` entry is written three ways: an envelope (`{value: {type,
202
+ * value}}`), a bare value (`{value: 'default'}`) or a named entry
203
+ * (`{name: 'default'}`). This has always accepted all three; the engine's
204
+ * clearing guard accepted only the first and the third, so a schema using the
205
+ * bare form had its value ERASED on load while this function went on rendering
206
+ * the display name for it — the row read "Default RBAC" collapsed and "—" when
207
+ * opened, and the value was gone from the submitted data.
208
+ */
209
+ export const findAllowedValueOption = (
210
+ value: unknown,
211
+ schema?: TQorusFormFieldSchema
212
+ ): any | undefined => {
139
213
  const s = schema as { allowed_values?: any[]; items?: any[] } | undefined;
140
214
  const options = s?.allowed_values?.length ? s.allowed_values : s?.items;
141
215
  if (!options?.length) {
@@ -154,6 +228,9 @@ const getAllowedValueLabel = (
154
228
  return match ? match.display_name || match.title || match.name || undefined : undefined;
155
229
  };
156
230
 
231
+ /** Local alias for the module's own callers. */
232
+ const findAllowedOption = findAllowedValueOption;
233
+
157
234
  /** True when the field's selectable options carry images (logos). Such a choice
158
235
  * renders too tall/rich for an inline row, so compact opens it in the card. */
159
236
  export const optionHasImages = (schema?: TQorusFormFieldSchema): boolean => {
@@ -64,6 +64,12 @@ export const ArrayAuto = ({
64
64
  }: IArrayAutoProps): any => {
65
65
  const confirmAction = useReqoreProperty('confirmAction');
66
66
  const [localValue, setLocalValue] = useState(value);
67
+ // The row this component just created, so its sub-form can open the field it
68
+ // cannot be saved without. Only a row added HERE gets it: the rows already in
69
+ // the value were added by an earlier session (or arrived from the server), and
70
+ // opening one of those on mount would be this component reopening a decision
71
+ // the author already made.
72
+ const [justAddedIndex, setJustAddedIndex] = useState<number | undefined>(undefined);
67
73
 
68
74
  useWhyDidYouUpdate(`Array Auto ${name}`, {
69
75
  name,
@@ -87,6 +93,7 @@ export const ArrayAuto = ({
87
93
  );
88
94
 
89
95
  const addValue: () => void = () => {
96
+ setJustAddedIndex(localValue.length);
90
97
  setLocalValue([...localValue, defaultValueByType[rest.type]]);
91
98
  };
92
99
 
@@ -171,6 +178,11 @@ export const ArrayAuto = ({
171
178
  allowTemplates
172
179
  defaultType={rest.type}
173
180
  name={`${name}-${idx}`}
181
+ // AFTER `{...rest}` on purpose: this is the one prop that differs
182
+ // per row, and spreading rest over it would give every row the same
183
+ // answer. Rides TemplateField's `rest` into the row's AutoFormField,
184
+ // which hands it to the row's nested FormEngine (case 'hash').
185
+ expandFirstRequired={Number(idx) === justAddedIndex}
174
186
  value={val}
175
187
  onChange={(_name, value) => handleChange(idx, value)}
176
188
  />
@@ -75,6 +75,10 @@ export interface IAutoFieldProps
75
75
  /** Render the arg_schema sub-form in compact (read-first) mode, matching the
76
76
  * parent engine. */
77
77
  compact?: boolean;
78
+ /** Open the arg_schema sub-form's first attention row on mount, without taking
79
+ * focus — see FormEngine's `expandFirstRequired`. Set by ArrayAuto on a
80
+ * just-added row so its required choice is visible without a second click. */
81
+ expandFirstRequired?: boolean;
78
82
  path?: string;
79
83
  column?: boolean;
80
84
  level?: number;
@@ -161,6 +165,10 @@ function AutoField<T = any>({
161
165
  // Only the nested arg_schema mount sites re-forward this into their
162
166
  // sub-forms explicitly.
163
167
  inheritedFromParent,
168
+ // Destructured for the same reason as `inheritedFromParent`: only the nested
169
+ // sub-form mount site consumes it, and leaving it in `...rest` would spread an
170
+ // unknown attribute onto the primitive field renderers' DOM nodes.
171
+ expandFirstRequired,
164
172
  ...rest
165
173
  }: IAutoFieldProps & T) {
166
174
  const [currentType, setType] = useState<IQorusType>(defaultInternalType || null);
@@ -559,6 +567,9 @@ function AutoField<T = any>({
559
567
  wrapperPadding='top'
560
568
  flat
561
569
  compact={compact}
570
+ // A row added just now: open the field it cannot be saved
571
+ // without, instead of making the author find it and click it.
572
+ expandFirstRequired={expandFirstRequired}
562
573
  // Embedded sub-form: no scroll context of its own, so its toolbar
563
574
  // isn't sticky and its header stays transparent (no dark backdrop).
564
575
  compactNested