@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
@@ -12,8 +12,14 @@ import {
12
12
  import { IReqoreDropdownItem } from '@qoretechnologies/reqore/dist/components/Dropdown/list';
13
13
  import { IReqorePanelAction } from '@qoretechnologies/reqore/dist/components/Panel';
14
14
  import { resolveOptionActions } from './optionActions';
15
- import { IQorusFormField, TQorusForm, TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit';
15
+ import {
16
+ IQorusFormField,
17
+ IQorusFormSchema,
18
+ TQorusForm,
19
+ TQorusFormFieldSchema,
20
+ } from '@qoretechnologies/ts-toolkit';
16
21
  import flatten from 'lodash/flatten';
22
+ import isEqual from 'lodash/isEqual';
17
23
  import size from 'lodash/size';
18
24
  import React, { memo } from 'react';
19
25
  import { useContextSelector } from 'use-context-selector';
@@ -26,8 +32,9 @@ import {
26
32
  splitTemplateTokens,
27
33
  TTemplateMeta,
28
34
  } from '../../../helpers/templates';
29
- import { richtextToSegments } from '../../../helpers/common';
35
+ import { richtextToSegments, richtextToString } from '../../../helpers/common';
30
36
  import { ReadOnlyTemplateTag } from '../fields/template/ReadOnlyTemplateTag';
37
+ import { describeCodeSize, formatCodeChars, formatCodeLines } from '../../codeSize';
31
38
  import { Description } from '../../Description';
32
39
  import { useMarkdownRenderer } from '../../Description/markdownRendererContext';
33
40
  import { FocusedEditing } from '../../FocusedEditing';
@@ -41,6 +48,7 @@ import {
41
48
  StyledColumn,
42
49
  StyledEditCard,
43
50
  StyledLabelBlock,
51
+ StyledAbsorbedField,
44
52
  StyledLabelDesc,
45
53
  StyledRowActions,
46
54
  StyledRowInset,
@@ -49,6 +57,8 @@ import {
49
57
  StyledStatusDot,
50
58
  } from './compactRowStyles';
51
59
  import { getShownSchemaMessages, getOptionFieldMessages } from './OptionFieldMessages';
60
+ import { SchemaDataView, canRenderWithSchema } from './_structuredData/SchemaDataView';
61
+ import { query } from '../../../utils/fetch';
52
62
  import {
53
63
  colorToCss,
54
64
  formatBytes,
@@ -129,6 +139,26 @@ const MAX_INLINE_OPTION_ACTIONS = 2;
129
139
  // One read-first row: label | value | action collapsed; the real editor (the
130
140
  // classic renderOption) expanded. `hidden` = search-surfaced optional —
131
141
  // activating the row adds the field first.
142
+ /**
143
+ * The text inside a rich-text envelope, when a list item is one.
144
+ *
145
+ * An option like Gmail's `to` is `type: "list"`, `element_type: "string"`,
146
+ * `supports_templates: true`: each element is a plain string that may carry
147
+ * template tags, so its editor is a rich-text one and the element value arrives
148
+ * wrapped as `{type: "richtext", value: [{type: "paragraph", …}]}`.
149
+ *
150
+ * That envelope is a string in a coat, not structured data. The hash-list test
151
+ * unwraps `item.value`, found the Slate document — an object — and classed a
152
+ * list of email addresses as a list of hashes, so the row drew an expandable
153
+ * `type / children / text` tree instead of the address the operator typed.
154
+ */
155
+ export const richtextItemText = (item: unknown): string | undefined =>
156
+ item && typeof item === 'object' && (item as { type?: unknown }).type === 'richtext' ?
157
+ richtextToString(
158
+ (item as { value?: Parameters<typeof richtextToString>[0] }).value
159
+ )
160
+ : undefined;
161
+
132
162
  export const CompactRow = memo(
133
163
  ({
134
164
  optionName,
@@ -137,10 +167,16 @@ export const CompactRow = memo(
137
167
  clustered = false,
138
168
  clusterFirst = false,
139
169
  clusterLast = false,
170
+ absorbedFields,
140
171
  }: {
141
172
  optionName: string;
142
173
  optionField: IQorusFormField;
143
174
  hidden?: boolean;
175
+ /** Siblings this row renders inside its own container instead of leaving
176
+ * them a row each — declared by the schema's `absorb_fields`. The pair it
177
+ * exists for is a code editor and its language: one decision, so one
178
+ * element. */
179
+ absorbedFields?: string[];
144
180
  // Rendered inside a required-group cluster: leading status node + rail, and no
145
181
  // per-row "one of" chip (the cluster header carries it). first/last trim the
146
182
  // rail segment so it spans node-to-node, not past the end members.
@@ -151,6 +187,13 @@ export const CompactRow = memo(
151
187
  const readOnly = useContextSelector(CompactRowContext, (v) => v.readOnly);
152
188
  const commitMode = useContextSelector(CompactRowContext, (v) => v.commitMode);
153
189
  const options = useContextSelector(CompactRowContext, (v) => v.options);
190
+
191
+ // An absorbed field keeps its own name — it is a different field, not a
192
+ // property of its host — so the label comes from its schema exactly as it
193
+ // would on its own row.
194
+ const getAbsorbedLabel = (absorbedName: string): string =>
195
+ (options?.[absorbedName] as { display_name?: string } | undefined)?.display_name ||
196
+ absorbedName;
154
197
  const codePreviewRenderer = useContextSelector(CompactRowContext, (v) => v.codePreviewRenderer);
155
198
  const markdownRenderer = useMarkdownRenderer();
156
199
  const operators = useContextSelector(CompactRowContext, (v) => v.operators);
@@ -213,6 +256,27 @@ export const CompactRow = memo(
213
256
 
214
257
  // Value-cell content: colour adds a swatch, file an icon + size; hash keeps
215
258
  // its "N fields" summary (sub-fields reveal beneath the row).
259
+ /**
260
+ * Flattens the line breaks in one prose segment of a richtext SUMMARY.
261
+ *
262
+ * The read-first row is a single line: every other value type reaches it
263
+ * through `whiteSpace: 'nowrap'`, which collapses newlines for free. The
264
+ * richtext branch is the only one that opts into `'pre'` — it has to, or the
265
+ * spaces that separate a word from the chip beside it are dropped — and `pre`
266
+ * also honours the newlines, which `nowrap` would have eaten.
267
+ *
268
+ * So a genuinely multi-line value (an alert rule's Gmail message body is five
269
+ * `\n`-separated lines) gave each segment after the first a blank first line.
270
+ * The wrapper centres its items, so a two-line-tall box centred against
271
+ * one-line chips put every word 12px below the chip beside it, and the row
272
+ * read as a staircase. Measured on supah: prose boxes 30px against 14px chips.
273
+ *
274
+ * A space, not nothing: consecutive prose segments are merged before they get
275
+ * here, so a value with a break and no chip between its lines would otherwise
276
+ * lose the word boundary entirely.
277
+ */
278
+ const collapseSummaryBreaks = (text: string): string => text.replace(/\s*\r?\n\s*/g, ' ');
279
+
216
280
  const renderReadFirstValue = (
217
281
  field: IQorusFormField,
218
282
  schema: TQorusFormFieldSchema | undefined,
@@ -290,17 +354,31 @@ export const CompactRow = memo(
290
354
  // see `showCodePreview` below. Keeps the row height fixed while making
291
355
  // multi-line source readable without opening the full editor.
292
356
  if (valueType === 'code-editor' && typeof field?.value === 'string') {
293
- const lines = field.value.split('\n').length;
294
- const chars = field.value.length;
357
+ // An absorbed sibling has no row of its own, so a COLLAPSED host has to
358
+ // carry its value or it disappears from the form entirely when closed.
359
+ // It reads as a chip beside the size chip — "Qore · 62 lines" — which
360
+ // is the same summary the read-only interface pages already show.
295
361
  return (
296
- <ReqoreTag
297
- size='small'
298
- minimal
299
- intent='info'
300
- icon='CodeLine'
301
- label={`${lines} ${lines === 1 ? 'line' : 'lines'}`}
302
- labelKey={`${chars} ${chars === 1 ? 'char' : 'chars'}`}
303
- />
362
+ <ReqoreControlGroup gapSize='tiny' verticalAlign='center' wrap>
363
+ {(absorbedFields ?? []).map((absorbedName) => {
364
+ const absorbedValue = (
365
+ availableOptions?.[absorbedName] as IQorusFormField | undefined
366
+ )?.value;
367
+ if (absorbedValue === undefined || absorbedValue === null || absorbedValue === '') {
368
+ return null;
369
+ }
370
+ return (
371
+ <ReqoreTag
372
+ key={absorbedName}
373
+ className='options-readfirst-absorbed-tag'
374
+ size='small'
375
+ minimal
376
+ label={String(absorbedValue)}
377
+ tooltip={getAbsorbedLabel(absorbedName)}
378
+ />
379
+ );
380
+ })}
381
+ </ReqoreControlGroup>
304
382
  );
305
383
  }
306
384
 
@@ -348,6 +426,15 @@ export const CompactRow = memo(
348
426
  return <ReadOnlyTemplateTag value={field.value} templates={templates} size='small' />;
349
427
  }
350
428
 
429
+ // A LIST whose elements are rich-text envelopes is a list of strings, so it
430
+ // reads as those strings. Without this the row fell through to the generic
431
+ // list summary and printed a bare count ("1 item") above the structured
432
+ // tree, never the address itself.
433
+ if (Array.isArray(field?.value) && field.value.some((item) => richtextItemText(item) !== undefined)) {
434
+ const texts = field.value.map((item) => richtextItemText(item) ?? String(item ?? ''));
435
+ return <span style={textStyle}>{texts.filter((text) => text !== '').join(', ')}</span>;
436
+ }
437
+
351
438
  // Richtext read-first summary: inline $-chips for embedded template tags
352
439
  // with the prose around them (the IDE intent scheme via getTemplateTagStyle),
353
440
  // kept lightweight instead of mounting the full editor — that lives in the
@@ -379,7 +466,7 @@ export const CompactRow = memo(
379
466
  )}
380
467
  />
381
468
  : <span key={index} style={{ whiteSpace: 'pre' }}>
382
- {segment.text}
469
+ {collapseSummaryBreaks(segment.text)}
383
470
  </span>
384
471
  )}
385
472
  </span>
@@ -637,6 +724,110 @@ export const CompactRow = memo(
637
724
  return () => window.clearTimeout(id);
638
725
  }, [isExpanded, optionName, autoFocusNameRef]);
639
726
 
727
+ // What the field held when it was opened — the baseline Cancel restores.
728
+ //
729
+ // Deliberately NOT `originalValue`, which is the whole form's load-time
730
+ // snapshot. That answers "undo every change ever made to this field", which
731
+ // is a different question from "forget what I just typed"; a Cancel sitting
732
+ // next to Done has to mean the second one, or it silently throws away edits
733
+ // the user made earlier and had already accepted.
734
+ //
735
+ // The dependency list is `isExpanded` alone on purpose: adding the value
736
+ // would re-take the snapshot on every keystroke, leaving nothing to cancel.
737
+ const editEntryValue = React.useRef<{ value: any; type?: string } | undefined>(undefined);
738
+ React.useEffect(() => {
739
+ if (isExpanded) {
740
+ editEntryValue.current = { value: optionField?.value, type: optionField?.type };
741
+ } else {
742
+ editEntryValue.current = undefined;
743
+ }
744
+ }, [isExpanded]);
745
+
746
+ // True while the open editor holds something other than what it opened with.
747
+ const editedSinceOpened =
748
+ isExpanded &&
749
+ !!editEntryValue.current &&
750
+ !isEqual(editEntryValue.current.value, optionField?.value);
751
+
752
+ // Discard whatever was typed since the field was opened and close it. Used
753
+ // by both the Cancel button and Escape, so the two cannot diverge.
754
+ const cancelEdit = React.useCallback(() => {
755
+ const snapshot = editEntryValue.current;
756
+ if (snapshot && !isEqual(snapshot.value, optionField?.value)) {
757
+ handleValueChange(optionName, snapshot.value, snapshot.type);
758
+ }
759
+ toggleExpandedOption(optionName);
760
+ }, [handleValueChange, optionField?.value, optionName, toggleExpandedOption]);
761
+
762
+ // The sub-schema describing this field's value, when it has one. A value the
763
+ // form knows the shape of is previewed THROUGH that shape (see
764
+ // `SchemaDataView`); only genuinely undescribed data falls through to the
765
+ // untyped data tree.
766
+ //
767
+ // The server does NOT inline that sub-schema. `encodeFieldsForUi()` registers
768
+ // it and sends an id string in its place, which the consumer fetches back —
769
+ // exactly what AutoFormField does to build the editor ("Some arg schemas are
770
+ // not provided as objects, but as strings so we need to fetch them"). A
771
+ // preview that understood only the inline object therefore fell through to
772
+ // the untyped tree for every field a real server describes: the schema view
773
+ // worked against hand-written story fixtures and nowhere else.
774
+ //
775
+ // Resolved here rather than beside the preview it feeds: the preview renders
776
+ // below an early return, so a hook placed there would run conditionally.
777
+ const rawArgSchema = (schema as { arg_schema?: IQorusFormSchema | string } | undefined)
778
+ ?.arg_schema;
779
+ const argSchemaId = typeof rawArgSchema === 'string' ? rawArgSchema : undefined;
780
+ const [resolvedArgSchema, setResolvedArgSchema] = React.useState<IQorusFormSchema | undefined>(
781
+ undefined
782
+ );
783
+
784
+ // Only worth a request when there is a described value to preview: a row whose
785
+ // value is unset or scalar renders no inset, so fetching its schema would buy
786
+ // a round trip per row and show nothing. `query` caches GETs, so rows sharing
787
+ // an id (every row of one list) cost one request between them.
788
+ const valueLooksStructured =
789
+ !!optionField?.value && typeof optionField.value === 'object';
790
+
791
+ React.useEffect(() => {
792
+ if (!argSchemaId || !valueLooksStructured) {
793
+ return undefined;
794
+ }
795
+ let cancelled = false;
796
+ (async () => {
797
+ const response = await query<IQorusFormSchema>({
798
+ url: `dataprovider/arg_schemas/${argSchemaId}`,
799
+ method: 'GET',
800
+ });
801
+ // A failed fetch is not an error state here: the preview simply stays the
802
+ // untyped tree, which is what it was before the schema view existed.
803
+ if (!cancelled && response.ok) {
804
+ setResolvedArgSchema(response.data as IQorusFormSchema);
805
+ }
806
+ })();
807
+ return () => {
808
+ cancelled = true;
809
+ };
810
+ }, [argSchemaId, valueLooksStructured]);
811
+
812
+ const previewArgSchema =
813
+ argSchemaId ? resolvedArgSchema : (rawArgSchema as IQorusFormSchema | undefined);
814
+ const previewWithSchema = React.useMemo(
815
+ () => canRenderWithSchema(optionField?.value, previewArgSchema),
816
+ [optionField?.value, previewArgSchema]
817
+ );
818
+
819
+ // The form-load revert: back to the value the FORM was loaded with.
820
+ //
821
+ // In an open editor this is offered only when it would do something
822
+ // `cancelEditButton` does not — that is, when the field was already
823
+ // modified BEFORE it was opened, so "undo this edit" and "undo every edit"
824
+ // are different answers. On the usual path (open an untouched field, type,
825
+ // change your mind) the two are identical, and two buttons with the same
826
+ // effect and different names are worse than one.
827
+ //
828
+ // It is unconditional on the READ row, where it renders inline in the
829
+ // status-dot column — there is no edit in progress there to cancel, so the
830
+ // whole-field revert is the only undo that makes sense.
640
831
  const revertButton =
641
832
  changed ?
642
833
  <ReqoreButton
@@ -718,6 +909,43 @@ export const CompactRow = memo(
718
909
  />
719
910
  : null;
720
911
  const isPassiveCloseAction = fixedAllowedValueOption || readOnly;
912
+ // Cancel — the counterpart to Done, and the answer to "how do I get out of
913
+ // here without keeping this". Done was the only way to leave an open field,
914
+ // so every way out committed: the check, clicking away, and Escape (which
915
+ // closed without discarding, so it committed too, silently).
916
+ //
917
+ // Shown only while there is something to discard. On an untouched field
918
+ // Cancel and Done would do exactly the same thing, and two buttons that
919
+ // differ in name but not in effect are worse than one.
920
+ //
921
+ // Not offered when the close action is passive (read-only, or a fixed
922
+ // choice) — nothing can have been typed, so there is nothing to cancel.
923
+ const cancelEditButton =
924
+ !isPassiveCloseAction && editedSinceOpened ?
925
+ <ReqoreButton
926
+ className='options-readfirst-cancel'
927
+ size='small'
928
+ flat
929
+ minimal
930
+ fixed
931
+ icon='CloseLine'
932
+ intent='warning'
933
+ // "Cancel edit", not "Cancel": a bare Cancel collides with the one
934
+ // in Reqore's confirmation modal, and this one is specifically about
935
+ // abandoning the edit rather than dismissing a dialog.
936
+ aria-label='Cancel edit'
937
+ tooltip='Cancel edit — discard the changes made since this field was opened'
938
+ onClick={(event: React.MouseEvent) => {
939
+ event.stopPropagation();
940
+ cancelEdit();
941
+ }}
942
+ />
943
+ : null;
944
+ // See `revertButton` above for why this is conditional in the edit row.
945
+ const openedWithFormLoadValue =
946
+ !editEntryValue.current ||
947
+ isEqual(editEntryValue.current.value, originalValue.current?.[optionName]?.value);
948
+ const editRowRevertButton = openedWithFormLoadValue ? null : revertButton;
721
949
  const closeExpandedFieldButton = (
722
950
  <ReqoreButton
723
951
  className='options-readfirst-done'
@@ -816,6 +1044,11 @@ export const CompactRow = memo(
816
1044
  />
817
1045
  : null;
818
1046
 
1047
+ // The gap rides on the message itself rather than on the panel around it:
1048
+ // the panel is a vertical ReqoreControlGroup, which owns its own layout and
1049
+ // stacks children flush, and the class rule that used to carry a 4px gap
1050
+ // never reached this element at all — which is why two messages read as one
1051
+ // two-tone block with no space between them.
819
1052
  const renderInfoStrip = (m: TInfoMsg, index: number) => (
820
1053
  <ReqoreMessage
821
1054
  key={`${m.content}-${index}`}
@@ -824,6 +1057,7 @@ export const CompactRow = memo(
824
1057
  flat
825
1058
  intent={m.intent as never}
826
1059
  title={m.title}
1060
+ style={{ marginBottom: 8 }}
827
1061
  >
828
1062
  {m.content}
829
1063
  </ReqoreMessage>
@@ -902,6 +1136,68 @@ export const CompactRow = memo(
902
1136
  />
903
1137
  );
904
1138
 
1139
+ // The absorbed siblings' controls, rendered inside this row.
1140
+ //
1141
+ // Each keeps its own label so it is still identifiable — it is a different
1142
+ // field, not a property of this one — but it sits inside this row's
1143
+ // container, above the editor, so the pair reads as the single decision it
1144
+ // is. `renderOption` is the same function the row uses for its own editor,
1145
+ // so an absorbed field renders exactly as it would have on its own row.
1146
+ const absorbedNodes =
1147
+ absorbedFields?.length ?
1148
+ absorbedFields
1149
+ .map((absorbedName) => {
1150
+ const absorbedField = availableOptions?.[absorbedName] as IQorusFormField | undefined;
1151
+ if (!absorbedField) return null;
1152
+ return (
1153
+ // No label element of its own: the editor now heads itself with
1154
+ // the field's name (see FieldAllowedValuesCheckGroup), so a label
1155
+ // beside it printed the same word twice.
1156
+ <StyledAbsorbedField key={absorbedName} className='options-readfirst-absorbed'>
1157
+ <div style={{ minWidth: 0, flex: 1 }}>
1158
+ {renderOption(absorbedName, absorbedField, 'small', true)}
1159
+ </div>
1160
+ </StyledAbsorbedField>
1161
+ );
1162
+ })
1163
+ .filter(Boolean)
1164
+ : null;
1165
+
1166
+ // Declared here rather than beside the hash rows below: the `isExpanded`
1167
+ // branch returns before that point, and its editor needs to know whether it is
1168
+ // holding code.
1169
+ const valueType = getValueType(optionField, schema);
1170
+
1171
+ // How much source there is describes the FIELD, not its value, so the count
1172
+ // sits under the field's name — the same rule `lineCountNote` already
1173
+ // follows for a multi-line markdown value, and the same reason: the value
1174
+ // column carries the value and nothing else. It renders on the read row and
1175
+ // the open one alike, because "how much code is here?" is a question that
1176
+ // outlives opening the editor.
1177
+ //
1178
+ // It is a plain monospaced note rather than a tag, and deliberately the same
1179
+ // one `lineCountNote` renders: both answer "how big is this field's value?"
1180
+ // in the same corner of the same column, so stating one as a chip and the
1181
+ // other as text made two spellings of one idea.
1182
+ const codeSizeNode =
1183
+ valueType === 'code-editor' &&
1184
+ typeof optionField?.value === 'string' &&
1185
+ optionField.value !== '' ?
1186
+ (() => {
1187
+ const { lines, chars } = describeCodeSize(optionField.value as string);
1188
+ return (
1189
+ <ReqoreP
1190
+ className='options-readfirst-label-code-size'
1191
+ size='tiny'
1192
+ effect={{ opacity: 0.45, textAlign: 'left' }}
1193
+ style={{ fontFamily: 'monospace' }}
1194
+ >
1195
+ {`${formatCodeChars(chars)} ${formatCodeLines(lines)}`}
1196
+ </ReqoreP>
1197
+ );
1198
+ })()
1199
+ : null;
1200
+
905
1201
  if (isExpanded) {
906
1202
  if (inlineEditable) {
907
1203
  const collapse = () => toggleExpandedOption(optionName);
@@ -917,7 +1213,7 @@ export const CompactRow = memo(
917
1213
  }
918
1214
  {...clusterHoverProps}
919
1215
  >
920
- <StyledLabelBlock>
1216
+ <StyledLabelBlock className='options-readfirst-label-block'>
921
1217
  <StyledRowLabel
922
1218
  role='button'
923
1219
  tabIndex={0}
@@ -952,6 +1248,7 @@ export const CompactRow = memo(
952
1248
  {labelShortDesc}
953
1249
  </StyledLabelDesc>
954
1250
  : null}
1251
+ {codeSizeNode}
955
1252
  </StyledLabelBlock>
956
1253
  <div
957
1254
  ref={editorRef}
@@ -959,10 +1256,52 @@ export const CompactRow = memo(
959
1256
  onKeyDown={(event) => {
960
1257
  if (event.key === 'Escape') {
961
1258
  event.stopPropagation();
962
- collapse();
1259
+ // Escape discards. It used to call `collapse()`, which closed
1260
+ // the field and KEPT whatever had been typed — the one
1261
+ // keystroke every editor treats as "get me out of this
1262
+ // without saving" was the quietest way to commit.
1263
+ cancelEdit();
963
1264
  }
964
1265
  }}
965
1266
  >
1267
+ {/* The size chip survives into the editor.
1268
+
1269
+ Opening a field replaces the whole value cell with its editor, which
1270
+ for every other type is right — the editor shows the value, so a
1271
+ summary of it above would say the same thing twice. Code is the
1272
+ exception: the chip counts lines and characters, which the editor
1273
+ does not show anywhere. Dropping it lost real information AND moved
1274
+ everything under it up by the height of a chip, so the editor
1275
+ appeared to jump as it opened.
1276
+
1277
+ It renders here, at the top of the value cell, which is exactly where
1278
+ it sits on the read row — so the switch moves nothing. */}
1279
+ {/* The field's own schema messages follow it into the editor.
1280
+
1281
+ A message is guidance about the value, so the moment it matters
1282
+ most is while the value is being changed — and a diagnostic is
1283
+ useless anywhere else: it names a line the author can only act
1284
+ on with the editor in front of them. They used to render on the
1285
+ READ row alone, so opening the field to fix what one said was
1286
+ what made it disappear. Worse for a host that opens a field on
1287
+ arrival: the message was never visible at all.
1288
+
1289
+ Above the editor rather than below it, and above the absorbed
1290
+ siblings, because it is the reason the author opened the row. */}
1291
+ {panelMessages.length ?
1292
+ <ReqoreControlGroup
1293
+ className='options-readfirst-info-panel'
1294
+ vertical
1295
+ fluid
1296
+ gapSize='normal'
1297
+ >
1298
+ {panelMessages.map(renderInfoStrip)}
1299
+ </ReqoreControlGroup>
1300
+ : null}
1301
+ {/* Absorbed siblings sit at the TOP of the value cell, above the
1302
+ editor they belong to — the language you are writing in is
1303
+ read before the code, not after it. */}
1304
+ {absorbedNodes}
966
1305
  {renderOption(optionName, optionField, 'small', true)}
967
1306
  </div>
968
1307
  <StyledRowActions>
@@ -971,7 +1310,8 @@ export const CompactRow = memo(
971
1310
  OptionFieldMessages strip below already says it — showing
972
1311
  both was redundant. The tag stays on READ rows, where no
973
1312
  message strip is visible. */}
974
- {revertButton}
1313
+ {editRowRevertButton}
1314
+ {cancelEditButton}
975
1315
  {clearValueButton}
976
1316
  {inlineOptionActions.map((action, index) =>
977
1317
  renderInjectedOptionAction(action, index)
@@ -1158,6 +1498,10 @@ export const CompactRow = memo(
1158
1498
  {closeExpandedFieldButton}
1159
1499
  </ReqoreControlGroup>
1160
1500
  </div>
1501
+ {/* Absorbed siblings render in the CARD form of an open field too,
1502
+ not only the inline row — which shape a field opens into depends
1503
+ on its type, and absorbing must not depend on that. */}
1504
+ {absorbedNodes}
1161
1505
  {/* Same fullscreen focused-editing affordance as the classic cards —
1162
1506
  the modal mounts when this option is focused. */}
1163
1507
  <FocusedEditing
@@ -1191,7 +1535,6 @@ export const CompactRow = memo(
1191
1535
  ];
1192
1536
  // A hash row reveals its sub-fields as read-only sub-rows under a "view
1193
1537
  // more" disclosure; the row itself still expands the real editor on click.
1194
- const valueType = getValueType(optionField, schema);
1195
1538
  const hashEntries =
1196
1539
  (
1197
1540
  !hidden &&
@@ -1210,6 +1553,9 @@ export const CompactRow = memo(
1210
1553
  Array.isArray(optionField?.value) &&
1211
1554
  optionField.value.length > 0 &&
1212
1555
  optionField.value.some((item) => {
1556
+ if (richtextItemText(item) !== undefined) {
1557
+ return false;
1558
+ }
1213
1559
  const inner =
1214
1560
  item && typeof item === 'object' && 'value' in (item as Record<string, unknown>) ?
1215
1561
  (item as Record<string, unknown>).value
@@ -1227,6 +1573,19 @@ export const CompactRow = memo(
1227
1573
  valueType === 'code-editor' &&
1228
1574
  typeof optionField?.value === 'string' &&
1229
1575
  optionField.value.length > 0;
1576
+ // A richtext value that renders as CHIPS is drawn in full by the row itself:
1577
+ // the chips are the value, and the reader is looking straight at it. The
1578
+ // native `title` below would then hover the raw source of the very thing the
1579
+ // chips replace ("[$fsminput:severity] $fsminput:alert_code") — and, because
1580
+ // each chip carries its own Reqore tooltip, hovering one fires BOTH: the chip
1581
+ // popover and the row's browser tooltip, in different places, saying almost
1582
+ // the same thing. Only the popover shows up in a screenshot, which is what
1583
+ // made the pair hard to report.
1584
+ const showsTemplateChips =
1585
+ !hidden &&
1586
+ valueType === 'richtext' &&
1587
+ Array.isArray(optionField?.value) &&
1588
+ richtextToSegments(optionField.value as never).some((segment) => segment.kind === 'tag');
1230
1589
  // Markdown reads the same way for the same reason: the row itself can only
1231
1590
  // show a line of text, and for markdown that line is the SOURCE — the reader
1232
1591
  // gets `## ` and `**` where the point of the value is what it looks like
@@ -1426,7 +1785,7 @@ export const CompactRow = memo(
1426
1785
  {...clusterHoverProps}
1427
1786
  >
1428
1787
  {clusterNode}
1429
- <StyledLabelBlock>
1788
+ <StyledLabelBlock className='options-readfirst-label-block'>
1430
1789
  <StyledRowLabel title={schema?.short_desc || undefined} $color={cKey}>
1431
1790
  {rowChromeIcon}
1432
1791
  {/* label + asterisk + help flow as ONE inline run, so the asterisk
@@ -1457,6 +1816,7 @@ export const CompactRow = memo(
1457
1816
  {labelShortDesc}
1458
1817
  </StyledLabelDesc>
1459
1818
  : null}
1819
+ {codeSizeNode}
1460
1820
  {lineCountNote ?
1461
1821
  <ReqoreP
1462
1822
  className='options-readfirst-label-lines'
@@ -1472,14 +1832,17 @@ export const CompactRow = memo(
1472
1832
  // A code field draws its whole value in the preview below, with
1473
1833
  // "Show more" for the rest, so a hover carrying the same text is
1474
1834
  // noise -- and a native tooltip holding a few hundred lines is
1475
- // unreadable anyway. Every other value keeps its hover, which is the
1476
- // only way to read one that the row had to truncate.
1835
+ // unreadable anyway. A chipped richtext value is the same case: the row
1836
+ // already draws it in full, and its chips carry their own tooltips.
1837
+ // Every other value keeps its hover, which is the only way to read one
1838
+ // that the row had to truncate.
1477
1839
  title={
1478
1840
  (
1479
1841
  !empty &&
1480
1842
  !hidden &&
1481
1843
  !showCodePreview &&
1482
1844
  !showMarkdownPreview &&
1845
+ !showsTemplateChips &&
1483
1846
  typeof formatted === 'string'
1484
1847
  ) ?
1485
1848
  formatted
@@ -1497,8 +1860,17 @@ export const CompactRow = memo(
1497
1860
  copy that used to sit above the document was a second rendering of
1498
1861
  the same text with the markers taken out (Qlip review, build #97).
1499
1862
  Without a renderer there is no inset, and the prose IS the value —
1500
- so the line stays, and `renderReadFirstValue` still produces it. */}
1501
- {showMarkdownPreview ? null : (
1863
+ so the line stays, and `renderReadFirstValue` still produces it.
1864
+
1865
+ A schema-previewed value is the same situation. The summary is a
1866
+ flattened join of the item labels ("Default RBAC, Cookie") sitting
1867
+ directly above a preview that names every one of those items and
1868
+ its fields — the same facts twice, the shorter version first, which
1869
+ reads less like a summary than like a second, disagreeing answer.
1870
+ An UNDESCRIBED value keeps its summary: the data tree beneath it
1871
+ says nothing about what the value MEANS, so the summary is still
1872
+ the only line that does. */}
1873
+ {showMarkdownPreview || previewWithSchema ? null : (
1502
1874
  <span className='options-readfirst-valuetext'>
1503
1875
  {hidden || empty ? '—' : renderReadFirstValue(optionField, schema, formatted)}
1504
1876
  </span>
@@ -1516,7 +1888,14 @@ export const CompactRow = memo(
1516
1888
  value (full width of the value column) — never pushed down by the
1517
1889
  label's short_desc. */}
1518
1890
  {panelMessages.length ?
1519
- <div className='options-readfirst-info-panel'>{panelMessages.map(renderInfoStrip)}</div>
1891
+ <ReqoreControlGroup
1892
+ className='options-readfirst-info-panel'
1893
+ vertical
1894
+ fluid
1895
+ gapSize='normal'
1896
+ >
1897
+ {panelMessages.map(renderInfoStrip)}
1898
+ </ReqoreControlGroup>
1520
1899
  : null}
1521
1900
  {/* The structured hash/list preview also lives in the value cell, so it
1522
1901
  starts directly under the value summary ("N fields"), not below the
@@ -1531,17 +1910,57 @@ export const CompactRow = memo(
1531
1910
  onClick={(e) => e.stopPropagation()}
1532
1911
  >
1533
1912
  <ReqoreCollapsibleContent
1534
- maxCollapsedHeight={96}
1913
+ // The schema view spends more height per datum than a data tree
1914
+ // does — a labelled row rather than a packed chip — but there is
1915
+ // far less of it to read, so it gets enough room to show a
1916
+ // couple of complete items instead of fading out mid-row. A tree
1917
+ // of unknown depth keeps the tighter cap.
1918
+ maxCollapsedHeight={previewWithSchema ? 140 : 96}
1535
1919
  buttonProps={{ className: 'options-readfirst-viewmore' }}
1536
1920
  >
1537
1921
  <div className='options-readfirst-structured'>
1538
- <StructuredDataView
1539
- value={optionField?.value}
1540
- collapsibleRoot={false}
1541
- showTypes={showFieldTypes}
1542
- defaultExpandDepth={2}
1543
- onItemClick={() => activate()}
1544
- />
1922
+ {previewWithSchema ?
1923
+ // The form already knows this value's shape, names and
1924
+ // choices, so the preview says it rather than making the
1925
+ // reader decode it: "Scheme Type — Default RBAC", not a data
1926
+ // tree announcing "Object · 1 field" over `type: default`.
1927
+ <SchemaDataView
1928
+ value={optionField?.value}
1929
+ schema={previewArgSchema!}
1930
+ showTypes={showFieldTypes}
1931
+ colors={{
1932
+ key: cKey,
1933
+ muted: cMuted,
1934
+ // Deliberately stronger than `cDivider`. That tier is for
1935
+ // decorative hairlines between rows, where being nearly
1936
+ // invisible is the point. This rule is load-bearing — it
1937
+ // is the only thing saying which fields belong to which
1938
+ // numbered item — so it gets the muted tier at partial
1939
+ // alpha: quiet, but actually visible.
1940
+ border: `${cMuted}66`,
1941
+ // The identity accent for each item's heading.
1942
+ //
1943
+ // `custom1` first: BRAND_DESIGN §1 says apps may register
1944
+ // their own named intents and reference them like the
1945
+ // built-ins, and that is where a product's own accent
1946
+ // lives (qorus-ide registers its brand purple there).
1947
+ // Falling back to `info` keeps this working for a
1948
+ // consumer that registers nothing — the heading is
1949
+ // accented either way, just in the theme's own colour
1950
+ // rather than one this library picked.
1951
+ accent:
1952
+ (theme?.intents as Record<string, string> | undefined)?.custom1 || cInfo,
1953
+ }}
1954
+ onItemClick={() => activate()}
1955
+ />
1956
+ : <StructuredDataView
1957
+ value={optionField?.value}
1958
+ collapsibleRoot={false}
1959
+ showTypes={showFieldTypes}
1960
+ defaultExpandDepth={2}
1961
+ onItemClick={() => activate()}
1962
+ />
1963
+ }
1545
1964
  </div>
1546
1965
  </ReqoreCollapsibleContent>
1547
1966
  </StyledRowInset>