@qoretechnologies/reqraft 0.10.53 → 0.10.54-pr.115.ge2d364c

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 (215) hide show
  1. package/.claude/CLAUDE.md +25 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/STORY_NETWORK_ISOLATION.md +98 -0
  4. package/README.md +1 -1
  5. package/design/FORM_ENGINE_EXPRESSIONS.md +110 -0
  6. package/design/RENDER_EXPRESSION_TRANSPORT.md +18 -0
  7. package/dist/components/dpqlEditor/dpqlProbe.d.ts +37 -0
  8. package/dist/components/dpqlEditor/dpqlProbe.d.ts.map +1 -0
  9. package/dist/components/dpqlEditor/dpqlProbe.js +203 -0
  10. package/dist/components/dpqlEditor/dpqlProbe.js.map +1 -0
  11. package/dist/components/dpqlEditor/index.d.ts +4 -0
  12. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  13. package/dist/components/dpqlEditor/index.js +5 -1
  14. package/dist/components/dpqlEditor/index.js.map +1 -1
  15. package/dist/components/dpqlEditor/types.d.ts +29 -2
  16. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  17. package/dist/components/dpqlEditor/useDpqlProbe.d.ts +4 -0
  18. package/dist/components/dpqlEditor/useDpqlProbe.d.ts.map +1 -0
  19. package/dist/components/dpqlEditor/useDpqlProbe.js +29 -0
  20. package/dist/components/dpqlEditor/useDpqlProbe.js.map +1 -0
  21. package/dist/components/dpqlEditor/useDpqlSession.d.ts +5 -2
  22. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  23. package/dist/components/dpqlEditor/useDpqlSession.js +8 -2
  24. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  25. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  26. package/dist/components/form/engine/CompactRow.js +186 -21
  27. package/dist/components/form/engine/CompactRow.js.map +1 -1
  28. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  29. package/dist/components/form/engine/CompactToolbar.js +5 -2
  30. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  31. package/dist/components/form/engine/FormEngine.d.ts +159 -11
  32. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  33. package/dist/components/form/engine/FormEngine.js +376 -75
  34. package/dist/components/form/engine/FormEngine.js.map +1 -1
  35. package/dist/components/form/engine/FormFieldsSkeleton.d.ts +48 -0
  36. package/dist/components/form/engine/FormFieldsSkeleton.d.ts.map +1 -0
  37. package/dist/components/form/engine/FormFieldsSkeleton.js +38 -0
  38. package/dist/components/form/engine/FormFieldsSkeleton.js.map +1 -0
  39. package/dist/components/form/engine/OptionFieldMessages.d.ts +17 -2
  40. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  41. package/dist/components/form/engine/OptionFieldMessages.js +61 -11
  42. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  43. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  44. package/dist/components/form/engine/_structuredData/SchemaDataView.js +7 -1
  45. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  46. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  47. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  48. package/dist/components/form/engine/readFirst.d.ts +42 -1
  49. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  50. package/dist/components/form/engine/readFirst.js +74 -7
  51. package/dist/components/form/engine/readFirst.js.map +1 -1
  52. package/dist/components/form/engine/rendererTypes.d.ts +13 -0
  53. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -1
  54. package/dist/components/form/engine/rendererTypes.js.map +1 -1
  55. package/dist/components/form/engine/rowMenuContext.d.ts +78 -0
  56. package/dist/components/form/engine/rowMenuContext.d.ts.map +1 -0
  57. package/dist/components/form/engine/rowMenuContext.js +103 -0
  58. package/dist/components/form/engine/rowMenuContext.js.map +1 -0
  59. package/dist/components/form/engine/typeChoices.d.ts +37 -0
  60. package/dist/components/form/engine/typeChoices.d.ts.map +1 -0
  61. package/dist/components/form/engine/typeChoices.js +25 -0
  62. package/dist/components/form/engine/typeChoices.js.map +1 -0
  63. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  64. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  65. package/dist/components/form/expressions/ExpressionField.js +181 -33
  66. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  67. package/dist/components/form/expressions/argumentPresence.d.ts +97 -0
  68. package/dist/components/form/expressions/argumentPresence.d.ts.map +1 -0
  69. package/dist/components/form/expressions/argumentPresence.js +203 -0
  70. package/dist/components/form/expressions/argumentPresence.js.map +1 -0
  71. package/dist/components/form/expressions/builder/argumentLabel.d.ts.map +1 -1
  72. package/dist/components/form/expressions/builder/argumentLabel.js +9 -4
  73. package/dist/components/form/expressions/builder/argumentLabel.js.map +1 -1
  74. package/dist/components/form/expressions/builder/index.d.ts +15 -2
  75. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  76. package/dist/components/form/expressions/builder/index.js +19 -57
  77. package/dist/components/form/expressions/builder/index.js.map +1 -1
  78. package/dist/components/form/expressions/dpqlMockLsp.d.ts +17 -0
  79. package/dist/components/form/expressions/dpqlMockLsp.d.ts.map +1 -1
  80. package/dist/components/form/expressions/dpqlMockLsp.js +75 -9
  81. package/dist/components/form/expressions/dpqlMockLsp.js.map +1 -1
  82. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  83. package/dist/components/form/expressions/renderExpressionToText.js +94 -19
  84. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  85. package/dist/components/form/fields/Field.d.ts.map +1 -1
  86. package/dist/components/form/fields/Field.js +3 -1
  87. package/dist/components/form/fields/Field.js.map +1 -1
  88. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +11 -1
  89. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  90. package/dist/components/form/fields/allowed-values/AllowedValues.js +14 -2
  91. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  92. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  93. package/dist/components/form/fields/auto/AutoFormField.js +13 -2
  94. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  95. package/dist/components/form/fields/rich-text/RichText.d.ts +11 -0
  96. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  97. package/dist/components/form/fields/rich-text/RichText.js +84 -5
  98. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  99. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +2 -1
  100. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  101. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -2
  102. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  103. package/dist/components/form/fields/template/TemplateField.d.ts +31 -1
  104. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  105. package/dist/components/form/fields/template/TemplateField.js +507 -40
  106. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  107. package/dist/components/form/index.d.ts +1 -0
  108. package/dist/components/form/index.d.ts.map +1 -1
  109. package/dist/components/form/index.js +1 -0
  110. package/dist/components/form/index.js.map +1 -1
  111. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts +1 -0
  112. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts.map +1 -1
  113. package/dist/components/smartEditor/__fixtures__/mockLspServer.js +6 -1
  114. package/dist/components/smartEditor/__fixtures__/mockLspServer.js.map +1 -1
  115. package/dist/helpers/dpqlDetection.d.ts +58 -0
  116. package/dist/helpers/dpqlDetection.d.ts.map +1 -0
  117. package/dist/helpers/dpqlDetection.js +164 -0
  118. package/dist/helpers/dpqlDetection.js.map +1 -0
  119. package/dist/helpers/optionUiTypes.d.ts +1 -1
  120. package/dist/helpers/options.d.ts +37 -0
  121. package/dist/helpers/options.d.ts.map +1 -1
  122. package/dist/helpers/options.js +78 -1
  123. package/dist/helpers/options.js.map +1 -1
  124. package/dist/helpers/templateItems.d.ts +47 -0
  125. package/dist/helpers/templateItems.d.ts.map +1 -0
  126. package/dist/helpers/templateItems.js +88 -0
  127. package/dist/helpers/templateItems.js.map +1 -0
  128. package/dist/helpers/templates.d.ts +21 -0
  129. package/dist/helpers/templates.d.ts.map +1 -1
  130. package/dist/helpers/templates.js +49 -3
  131. package/dist/helpers/templates.js.map +1 -1
  132. package/dist/helpers/validations.d.ts.map +1 -1
  133. package/dist/helpers/validations.js +56 -9
  134. package/dist/helpers/validations.js.map +1 -1
  135. package/dist/hooks/useArgSchema.d.ts +2 -1
  136. package/dist/hooks/useArgSchema.d.ts.map +1 -1
  137. package/dist/hooks/useFetch/useFetch.d.ts +3 -1
  138. package/dist/hooks/useFetch/useFetch.d.ts.map +1 -1
  139. package/dist/hooks/useFetch/useFetch.js +47 -9
  140. package/dist/hooks/useFetch/useFetch.js.map +1 -1
  141. package/dist/hooks/useTemplates.d.ts +20 -0
  142. package/dist/hooks/useTemplates.d.ts.map +1 -1
  143. package/dist/hooks/useTemplates.js +34 -2
  144. package/dist/hooks/useTemplates.js.map +1 -1
  145. package/dist/index.d.ts +2 -1
  146. package/dist/index.d.ts.map +1 -1
  147. package/dist/index.js +5 -1
  148. package/dist/index.js.map +1 -1
  149. package/dist/stories/storyNetwork.d.ts +36 -0
  150. package/dist/stories/storyNetwork.d.ts.map +1 -0
  151. package/dist/stories/storyNetwork.js +82 -0
  152. package/dist/stories/storyNetwork.js.map +1 -0
  153. package/dist/utils/fetch.d.ts +151 -1
  154. package/dist/utils/fetch.d.ts.map +1 -1
  155. package/dist/utils/fetch.js +256 -44
  156. package/dist/utils/fetch.js.map +1 -1
  157. package/dist/utils/lspClient.d.ts +6 -0
  158. package/dist/utils/lspClient.d.ts.map +1 -1
  159. package/dist/utils/lspClient.js +62 -11
  160. package/dist/utils/lspClient.js.map +1 -1
  161. package/dist/utils/websocket.d.ts +6 -0
  162. package/dist/utils/websocket.d.ts.map +1 -1
  163. package/dist/utils/websocket.js +15 -7
  164. package/dist/utils/websocket.js.map +1 -1
  165. package/package.json +2 -2
  166. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +0 -2
  167. package/src/components/dpqlEditor/dpqlProbe.ts +165 -0
  168. package/src/components/dpqlEditor/index.ts +4 -0
  169. package/src/components/dpqlEditor/types.ts +26 -2
  170. package/src/components/dpqlEditor/useDpqlProbe.ts +33 -0
  171. package/src/components/dpqlEditor/useDpqlSession.ts +20 -4
  172. package/src/components/form/engine/CompactRow.tsx +231 -11
  173. package/src/components/form/engine/CompactToolbar.tsx +10 -3
  174. package/src/components/form/engine/FormEngine.stories.tsx +331 -30
  175. package/src/components/form/engine/FormEngine.tsx +504 -87
  176. package/src/components/form/engine/FormFieldsSkeleton.tsx +76 -0
  177. package/src/components/form/engine/OptionFieldMessages.tsx +89 -11
  178. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +11 -0
  179. package/src/components/form/engine/compactToolbarContext.ts +1 -1
  180. package/src/components/form/engine/readFirst.ts +86 -7
  181. package/src/components/form/engine/rendererTypes.ts +12 -0
  182. package/src/components/form/engine/rowMenuContext.ts +159 -0
  183. package/src/components/form/engine/typeChoices.ts +55 -0
  184. package/src/components/form/expressions/ExpressionField.stories.tsx +828 -31
  185. package/src/components/form/expressions/ExpressionField.tsx +234 -29
  186. package/src/components/form/expressions/argumentPresence.ts +200 -0
  187. package/src/components/form/expressions/builder/argumentLabel.tsx +10 -4
  188. package/src/components/form/expressions/builder/index.tsx +31 -75
  189. package/src/components/form/expressions/dpqlMockLsp.ts +81 -1
  190. package/src/components/form/expressions/renderExpressionToText.ts +106 -17
  191. package/src/components/form/fields/Field.tsx +3 -2
  192. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -2
  193. package/src/components/form/fields/auto/AutoFormField.tsx +12 -2
  194. package/src/components/form/fields/rich-text/RichText.tsx +112 -19
  195. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -2
  196. package/src/components/form/fields/template/TemplateField.tsx +667 -95
  197. package/src/components/form/index.tsx +1 -0
  198. package/src/components/log/Log.stories.tsx +2 -1
  199. package/src/components/smartEditor/__fixtures__/mockLspServer.ts +6 -1
  200. package/src/helpers/dpqlDetection.ts +209 -0
  201. package/src/helpers/options.ts +70 -0
  202. package/src/helpers/templateItems.ts +81 -0
  203. package/src/helpers/templates.ts +51 -2
  204. package/src/helpers/validations.ts +59 -9
  205. package/src/hooks/useFetch/useFetch.stories.tsx +6 -5
  206. package/src/hooks/useFetch/useFetch.tsx +53 -7
  207. package/src/hooks/useStorage/useStorage.stories.tsx +2 -1
  208. package/src/hooks/useTemplates.ts +41 -0
  209. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +2 -1
  210. package/src/index.tsx +14 -1
  211. package/src/stores/currentUser/currentUser.stories.tsx +2 -1
  212. package/src/stories/storyNetwork.ts +92 -0
  213. package/src/utils/fetch.ts +311 -21
  214. package/src/utils/lspClient.ts +68 -11
  215. package/src/utils/websocket.ts +20 -9
@@ -0,0 +1,33 @@
1
+ // Copyright 2026 Qore Technologies, s.r.o.
2
+ // React access to the shared DPQL detection probe (`dpqlProbe`). The hook
3
+ // itself opens nothing: a field becomes a consumer of the shared document
4
+ // only once it actually asks a question, and stops being one when it
5
+ // unmounts. A form full of fields that never see expression-shaped text
6
+ // therefore never opens the document at all.
7
+ import { useCallback, useEffect, useRef } from 'react';
8
+ import { dpqlProbe, IDpqlProbeResult } from './dpqlProbe';
9
+
10
+ export type TDpqlProbeFn = (text: string) => Promise<IDpqlProbeResult>;
11
+
12
+ export const useDpqlProbe = (): TDpqlProbeFn => {
13
+ const acquired = useRef(false);
14
+
15
+ useEffect(
16
+ () => () => {
17
+ if (acquired.current) {
18
+ acquired.current = false;
19
+ dpqlProbe.release();
20
+ }
21
+ },
22
+ []
23
+ );
24
+
25
+ return useCallback((text: string): Promise<IDpqlProbeResult> => {
26
+ if (!acquired.current) {
27
+ acquired.current = true;
28
+ dpqlProbe.acquire();
29
+ }
30
+
31
+ return dpqlProbe.parse(text);
32
+ }, []);
33
+ };
@@ -50,8 +50,11 @@ export interface IUseDpqlSessionResult {
50
50
  fieldMeta: Record<string, IDpqlFieldMeta>;
51
51
  /** Most recent `publishDiagnostics`. */
52
52
  diagnostics: ILspDiagnostic[];
53
- /** `dpql/parse` — DPQL text → expression AST. */
54
- parse: (text: string) => Promise<IDpqlParseResult>;
53
+ /**
54
+ * `dpql/parse` — DPQL text → expression AST, and, when `targetType` is
55
+ * given, whether that result can satisfy the type the field declares.
56
+ */
57
+ parse: (text: string, targetType?: string) => Promise<IDpqlParseResult>;
55
58
  /** `dpql/serialize` — expression AST → DPQL text. */
56
59
  serialize: (expression: Record<string, any>) => Promise<string>;
57
60
  /**
@@ -314,18 +317,31 @@ export function useDpqlSession(
314
317
  );
315
318
 
316
319
  const parse = useCallback(
317
- async (text: string): Promise<IDpqlParseResult> => {
320
+ async (text: string, targetType?: string): Promise<IDpqlParseResult> => {
318
321
  if (!session.client) {
319
322
  return { success: false, expression: null, diagnostics: [] };
320
323
  }
321
324
  try {
322
325
  const result = await session.client.customRequest<Partial<IDpqlParseResult>>(
323
326
  'dpql/parse',
324
- { uri: session.uri, text }
327
+ {
328
+ uri: session.uri,
329
+ text,
330
+ // Only sent when the field declares one. Without it the server
331
+ // does no type analysis at all, which is the right answer for a
332
+ // caller that only wants to know whether the text parses.
333
+ ...(targetType ? { target_type: targetType } : {}),
334
+ }
325
335
  );
326
336
  return {
327
337
  success: result?.success ?? false,
328
338
  expression: result?.expression ?? null,
339
+ inferred_type: result?.inferred_type,
340
+ target_type: result?.target_type,
341
+ type_compatible: result?.type_compatible,
342
+ auto_coercible: result?.auto_coercible,
343
+ coercion_may_fail: result?.coercion_may_fail,
344
+ suggested_fix: result?.suggested_fix,
329
345
  diagnostics: result?.diagnostics ?? [],
330
346
  };
331
347
  } catch {
@@ -10,6 +10,7 @@ import {
10
10
  ReqoreTagGroup,
11
11
  } from '@qoretechnologies/reqore';
12
12
  import { IReqoreDropdownItem } from '@qoretechnologies/reqore/dist/components/Dropdown/list';
13
+ import { RowMenuContext, useRowMenuRegistry } from './rowMenuContext';
13
14
  import { IReqorePanelAction } from '@qoretechnologies/reqore/dist/components/Panel';
14
15
  import { resolveOptionActions } from './optionActions';
15
16
  import {
@@ -30,6 +31,7 @@ import {
30
31
  isValueTemplate,
31
32
  describeTemplateReference,
32
33
  splitTemplateTokens,
34
+ templateTooltip,
33
35
  TTemplateMeta,
34
36
  } from '../../../helpers/templates';
35
37
  import { getDefaultValue, richtextToSegments, richtextToString } from '../../../helpers/common';
@@ -64,6 +66,7 @@ import {
64
66
  findAllowedValueOption,
65
67
  formatBytes,
66
68
  formatOptionValue,
69
+ getAllowedValueIcon,
67
70
  getAllowedValueImage,
68
71
  getFileSize,
69
72
  getHashEntries,
@@ -72,8 +75,10 @@ import {
72
75
  isFixedCompactAllowedValueOption,
73
76
  isOptionValueEmpty,
74
77
  optionHasImages,
78
+ getExpressionAst,
75
79
  } from './readFirst';
76
80
  import { StructuredDataView } from './_structuredData/StructuredDataView';
81
+ import { TFieldWithOwnTemplates } from './rendererTypes';
77
82
 
78
83
  /**
79
84
  * Does this markdown value have more document in it than one row can show?
@@ -299,6 +304,39 @@ export const CompactRow = memo(
299
304
  { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }
300
305
  : { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
301
306
 
307
+ /* A row has to SAY when its value is an expression.
308
+
309
+ Reported from the live IDE: an author accepted the *Use as expression*
310
+ offer on one field and not on the other, and the two rows read exactly
311
+ the same - `1 + 2` and `2 + 1`, both plain text - so there was no way
312
+ to tell which had been converted. The assertion then compared the
313
+ string `"1 + 2"` against the number `3` and the author had no way to
314
+ see why.
315
+
316
+ The same Σ the expression editor uses, so the mark means the same thing
317
+ wherever it appears. */
318
+ const withExpressionMarker = (rendered: React.ReactNode): React.ReactNode => {
319
+ if (!(field as { is_expression?: boolean })?.is_expression) {
320
+ return rendered;
321
+ }
322
+
323
+ return (
324
+ <span style={{ ...wrapStyle, gap: 4 }}>
325
+ <ReqoreIcon
326
+ icon='Functions'
327
+ size='tiny'
328
+ /* A stable hook: reqore renders every icon as a bare
329
+ `.reqore-icon`, so without this there is nothing to select the
330
+ mark by — in a test or in a consumer's stylesheet. */
331
+ className='reqraft-expression-marker'
332
+ style={{ flexShrink: 0, opacity: 0.7 }}
333
+ tooltip='This value is an expression'
334
+ />
335
+ {rendered}
336
+ </span>
337
+ );
338
+ };
339
+
302
340
  if (valueType === 'rgbcolor') {
303
341
  const swatch = colorToCss(field?.value);
304
342
  return (
@@ -351,6 +389,52 @@ export const CompactRow = memo(
351
389
  );
352
390
  }
353
391
 
392
+ // A test's cases read as CHIPS here, the same as the names in the table
393
+ // this row opens (`CasesTable`, qorus-ide). `test-cases` is a
394
+ // renderer-only ui_type, so it reached none of the branches above and
395
+ // fell through to the generic summary, which flattens the array of case
396
+ // hashes to a comma-joined string — "case_1, resolved_expected_values".
397
+ // A case name is an identifier the author types into `$.` references and
398
+ // skip conditions, and a chip is how the rest of the product writes an
399
+ // identifier down; the table already agreed, so the collapsed row was the
400
+ // only surface still printing them as prose.
401
+ //
402
+ // `intent` is a FILL, which is what a tag is — the hazard the other
403
+ // branches warn about is only ever using an intent as a TEXT colour.
404
+ //
405
+ // Falls through to the plain summary unless EVERY entry yields a name: a
406
+ // partly-chipped row would read as though the unnamed cases were missing.
407
+ if (valueType === 'test-cases' && Array.isArray(field?.value)) {
408
+ const caseNames = (field.value as unknown[]).map((item) => {
409
+ const name = (item as { name?: unknown } | null | undefined)?.name;
410
+ return typeof name === 'string' ? name.trim() : '';
411
+ });
412
+ if (caseNames.length > 0 && caseNames.every((name) => name !== '')) {
413
+ return (
414
+ <ReqoreControlGroup
415
+ gapSize='tiny'
416
+ verticalAlign='center'
417
+ // An editable row is ONE line: further chips clip with the row
418
+ // instead of growing it. A read-only row is the only rendering
419
+ // the value gets, so it wraps and shows every case.
420
+ wrap={full}
421
+ style={full ? undefined : { minWidth: 0, overflow: 'hidden' }}
422
+ >
423
+ {caseNames.map((name, index) => (
424
+ <ReqoreTag
425
+ key={`${name}-${index}`}
426
+ className='options-readfirst-case-tag'
427
+ size='small'
428
+ minimal
429
+ intent='info'
430
+ label={name}
431
+ />
432
+ ))}
433
+ </ReqoreControlGroup>
434
+ );
435
+ }
436
+ }
437
+
354
438
  // Code-editor read summary: replace the truncated string with a chip
355
439
  // showing line count + character count. The actual code renders below
356
440
  // the row as a monospace `<pre>` inside a `ReqoreCollapsibleContent` —
@@ -418,15 +502,40 @@ export const CompactRow = memo(
418
502
  );
419
503
  }
420
504
 
505
+ // The same, for an option whose mark is an ICON rather than a logo. Only
506
+ // images were drawn here, so a list whose entries are told apart by their
507
+ // icon — every interface kind is a bare word, `Service`, `Job`, `Qog` —
508
+ // showed the mark while choosing and lost it the moment the row went
509
+ // read-only, which is where a reader spends most of their time.
510
+ const allowedIcon = getAllowedValueIcon(field?.value, schema);
511
+ if (allowedIcon) {
512
+ return (
513
+ <span style={wrapStyle}>
514
+ <ReqoreIcon icon={allowedIcon as any} size='16px' style={{ flexShrink: 0 }} />
515
+ <span style={textStyle}>{formatted}</span>
516
+ </span>
517
+ );
518
+ }
519
+
421
520
  // Template value ($local:…): the read-only template picker — the SAME chip
422
521
  // TemplateField renders when disabled, so a template reads identically here
423
522
  // and in the editor (qorus-ide intent scheme: info / qorus purple).
523
+ //
524
+ // A field may also declare templates OF ITS OWN, and a value drawn from
525
+ // that list is just as much a template even when it does not look like
526
+ // `$name:key` — `isValueTemplate` is a guess at the shape of the built-in
527
+ // grammar, and a host's own grammar (a Qorus test's `$.order.id`) fails it.
528
+ // Asking the field's list whether it CONTAINS the value settles it without
529
+ // guessing at syntax, and hands back the entry whose label the chip prints,
530
+ // so a reference reads as the name it was chosen by here as well as in the
531
+ // editor rather than as a raw path.
532
+ const ownTemplates = (schema as TFieldWithOwnTemplates | undefined)?.templates ?? templates;
424
533
  if (
425
534
  typeof field?.value === 'string' &&
426
535
  !(field as { is_expression?: boolean }).is_expression &&
427
- isValueTemplate(field.value)
536
+ (isValueTemplate(field.value) || !!findTemplate(ownTemplates ?? {}, field.value))
428
537
  ) {
429
- return <ReadOnlyTemplateTag value={field.value} templates={templates} size='small' />;
538
+ return <ReadOnlyTemplateTag value={field.value} templates={ownTemplates} size='small' />;
430
539
  }
431
540
 
432
541
  // A LIST whose elements are rich-text envelopes is a list of strings, so it
@@ -465,7 +574,9 @@ export const CompactRow = memo(
465
574
  style={{ fontSize: 'inherit' }}
466
575
  icon='ExchangeDollarLine'
467
576
  label={segment.text || segment.value}
468
- tooltip={segment.value}
577
+ // The same card the chosen-template chip shows: what the
578
+ // value IS, not the reference that names it.
579
+ tooltip={templateTooltip(templates, segment.value)}
469
580
  {...getTemplateTagStyle(
470
581
  (templates ? findTemplate(templates, segment.value || '') : undefined)
471
582
  ?.metadata as TTemplateMeta | undefined
@@ -499,7 +610,7 @@ export const CompactRow = memo(
499
610
  );
500
611
 
501
612
  if (named.some((segment) => segment.kind === 'token')) {
502
- return (
613
+ return withExpressionMarker(
503
614
  <span style={{ ...wrapStyle, gap: 4, overflow: 'hidden' }}>
504
615
  {named.map((segment, index) =>
505
616
  segment.kind === 'token' ?
@@ -523,7 +634,7 @@ export const CompactRow = memo(
523
634
  }
524
635
  }
525
636
 
526
- return full ? <span style={textStyle}>{formatted}</span> : formatted;
637
+ return withExpressionMarker(full ? <span style={textStyle}>{formatted}</span> : formatted);
527
638
  };
528
639
 
529
640
  const schema = options?.[optionName];
@@ -627,8 +738,23 @@ export const CompactRow = memo(
627
738
  const changed = !hidden && !readOnly && hasOptionChanged(optionField?.value, optionName);
628
739
  // "Has a value" = set to anything non-empty. Drives the edit-row Clear
629
740
  // button and the cluster node's filled state (see `memberSet`).
741
+ /* "Is there anything to clear?" — this predicate has exactly one job, and
742
+ both of its uses are the Clear-value button.
743
+
744
+ An empty list is not a value. `[]` is neither `undefined`, `null` nor `''`,
745
+ so a field whose last item had just been removed went on offering to clear
746
+ it, and the confirmation asked whether to clear nothing. A test's cases
747
+ table is where this shows: delete the last case and a red clear-all sat
748
+ above an empty table, guarding a no-op.
749
+
750
+ Deliberately arrays only. An empty hash is arguable — "present but empty"
751
+ can be a real state for a hash — and nothing has demonstrated the same
752
+ defect there, so widening this predicate would be a guess. */
630
753
  const hasValue =
631
- optionField?.value !== undefined && optionField?.value !== null && optionField?.value !== '';
754
+ optionField?.value !== undefined &&
755
+ optionField?.value !== null &&
756
+ optionField?.value !== '' &&
757
+ !(Array.isArray(optionField.value) && optionField.value.length === 0);
632
758
  // Required-group membership shows a PERSISTENT chip on every member: amber
633
759
  // "One of" while the group is unmet (tap → flash siblings), then a muted-green
634
760
  // resolution once satisfied — "Covers" on the field that satisfied it,
@@ -705,7 +831,7 @@ export const CompactRow = memo(
705
831
  : (schema?.ui_type as string) || (schema?.type as string)) ?? '';
706
832
  // Scalars edit in place inside the row; complex fields (tall or nested
707
833
  // editors) still open the expanded card below.
708
- const inlineEditable =
834
+ const inlineEditableNow =
709
835
  !readOnly &&
710
836
  !schema?.arg_schema &&
711
837
  !(operators && size(operators)) &&
@@ -715,6 +841,43 @@ export const CompactRow = memo(
715
841
  !optionHasImages(schema) &&
716
842
  !COMPACT_COMPLEX_TYPES.has(editType);
717
843
 
844
+ /* Which of the two editors a row gets is settled when the row OPENS.
845
+ *
846
+ * Every term above reads the VALUE or a schema derived from it, so the
847
+ * answer can change while the author is inside the control — and the two
848
+ * branches are different subtrees, so a changed answer does not restyle
849
+ * the editor, it unmounts one and mounts the other. Everything the old one
850
+ * held goes with it: what was typed, the caret, and any state the editor
851
+ * keeps about how it was entered.
852
+ *
853
+ * `is_expression` is the term that actually fires, and it fires on the one
854
+ * interaction that exists to keep the author where they are: accepting
855
+ * "Use as expression" is the moment the value becomes an expression, and
856
+ * the expression shell opens on its Text view precisely because the author
857
+ * has just written the expression as text. The remount discarded that and
858
+ * dropped them into the visual builder, so the sentence they had typed was
859
+ * nowhere on screen. Clicking Text then appeared to work and bounced back,
860
+ * because the next value change remounted the row again.
861
+ *
862
+ * So the row keeps the editor it opened with for as long as it is open,
863
+ * and asks the question again when it is next opened — the same trade
864
+ * `editEntryValue` above makes for a different reason, and the one
865
+ * qorus-ide's `referenceEditorLatch` makes for this exact one. A field that
866
+ * already holds an expression when it is opened still gets the card; it is
867
+ * only a field that BECOMES one under the author that stays put.
868
+ *
869
+ * Held in a ref and settled during render rather than in an effect: an
870
+ * effect lands one render late, and one render late is a visible flash of
871
+ * the wrong editor — which is the thing being fixed.
872
+ */
873
+ const inlineEditableLatch = React.useRef<boolean | undefined>(undefined);
874
+ if (!isExpanded) {
875
+ inlineEditableLatch.current = undefined;
876
+ } else if (inlineEditableLatch.current === undefined) {
877
+ inlineEditableLatch.current = inlineEditableNow;
878
+ }
879
+ const inlineEditable = isExpanded ? !!inlineEditableLatch.current : inlineEditableNow;
880
+
718
881
  // Auto-focus the editor's first input when a field is opened, so you can type
719
882
  // straight away (matches the prototype's tap-to-edit feel).
720
883
  const editorRef = React.useRef<HTMLDivElement>(null);
@@ -1061,6 +1224,19 @@ export const CompactRow = memo(
1061
1224
  // stacks children flush, and the class rule that used to carry a 4px gap
1062
1225
  // never reached this element at all — which is why two messages read as one
1063
1226
  // two-tone block with no space between them.
1227
+ /* A message is PROSE, so it is drawn by the host's prose renderer.
1228
+
1229
+ `markdownRendererContext` says this package renders markdown "in exactly
1230
+ one place — a field's description". That was one place short: a schema
1231
+ message is written by the same author, in the same dialect, and it landed
1232
+ here as a raw string. A Qorus warning reading "Give that step a **Fixture
1233
+ Output**" showed the asterisks, and every reference inside it stayed a raw
1234
+ `$.` path where the editor two lines above renders it as a named chip.
1235
+
1236
+ `compact` for the same reason the row inset asks for it: this is a strip
1237
+ inside a row, never a page, and markdown authored as a document opens with
1238
+ a heading that would outgrow the field label above it. Without a host
1239
+ renderer the string is drawn as it always was. */
1064
1240
  const renderInfoStrip = (m: TInfoMsg, index: number) => (
1065
1241
  <ReqoreMessage
1066
1242
  key={`${m.content}-${index}`}
@@ -1071,7 +1247,9 @@ export const CompactRow = memo(
1071
1247
  title={m.title}
1072
1248
  style={{ marginBottom: 8 }}
1073
1249
  >
1074
- {m.content}
1250
+ {markdownRenderer && typeof m.content === 'string' ?
1251
+ markdownRenderer({ value: m.content, compact: true })
1252
+ : m.content}
1075
1253
  </ReqoreMessage>
1076
1254
  );
1077
1255
  const reasonColor = (intent?: string) =>
@@ -1108,6 +1286,9 @@ export const CompactRow = memo(
1108
1286
  }
1109
1287
  : {};
1110
1288
 
1289
+ // Items the EDITOR inside this row published — see `rowMenuContext`.
1290
+ const { rowMenu, items: editorMenuItems } = useRowMenuRegistry();
1291
+
1111
1292
  // Secondary edit actions tuck into a "More" (⋮) menu so the card header stays
1112
1293
  // calm: Fullscreen always, plus Remove field for a removable option. Rendered
1113
1294
  // just before the Done ✓ in both expanded layouts.
@@ -1144,6 +1325,10 @@ export const CompactRow = memo(
1144
1325
  // wholesale on touch) land here — this row already has a menu, so they
1145
1326
  // reuse it rather than adding a second one beside it.
1146
1327
  ...injectedOptionActionMenuItems,
1328
+ // ...and for the same reason, so do the editor's own actions. Without
1329
+ // this an editor with affordances of its own had to draw a second ⋮
1330
+ // inside the value cell, beside this one.
1331
+ ...editorMenuItems,
1147
1332
  ]}
1148
1333
  />
1149
1334
  );
@@ -1316,7 +1501,13 @@ export const CompactRow = memo(
1316
1501
  editor they belong to — the language you are writing in is
1317
1502
  read before the code, not after it. */}
1318
1503
  {absorbedNodes}
1319
- {renderOption(optionName, optionField, 'small', true)}
1504
+ {/* The editor may publish into THIS row's ⋮ — see `rowMenuContext`.
1505
+ Only the editors rendered in the row are given the channel: the
1506
+ fullscreen modal below has no row menu on screen, so an editor
1507
+ there keeps drawing its own control. */}
1508
+ <RowMenuContext.Provider value={rowMenu}>
1509
+ {renderOption(optionName, optionField, 'small', true)}
1510
+ </RowMenuContext.Provider>
1320
1511
  </div>
1321
1512
  <StyledRowActions>
1322
1513
  {draftChip}
@@ -1556,7 +1747,9 @@ export const CompactRow = memo(
1556
1747
  longDescriptionShownByDefault
1557
1748
  />
1558
1749
  : null}
1559
- {renderOption(optionName, optionField)}
1750
+ <RowMenuContext.Provider value={rowMenu}>
1751
+ {renderOption(optionName, optionField)}
1752
+ </RowMenuContext.Provider>
1560
1753
  </FocusedEditing>
1561
1754
  </div>
1562
1755
  </StyledEditCard>
@@ -1596,11 +1789,21 @@ export const CompactRow = memo(
1596
1789
  ];
1597
1790
  // A hash row reveals its sub-fields as read-only sub-rows under a "view
1598
1791
  // more" disclosure; the row itself still expands the real editor on click.
1792
+ /* An EXPRESSION is not a hash, whatever shape it is stored in.
1793
+
1794
+ `{is_expression: true, value: {exp, args}}` is hash-shaped, so a field
1795
+ holding one earned the structured inset and the row printed the syntax
1796
+ tree — `is_expression true / value / exp + / args 1 2` — directly under a
1797
+ summary line that already read `1 + 2`. The AST is how an expression is
1798
+ STORED, not what it is; the row's line is the whole value, and the
1799
+ expression editor is where its parts are looked at. Same exclusion, and
1800
+ for the same reason, as `schema-definition` beside it. */
1599
1801
  const hashEntries =
1600
1802
  (
1601
1803
  !hidden &&
1602
1804
  (valueType === 'hash' || valueType === 'free-hash') &&
1603
- (schema as { ui_type?: string } | undefined)?.ui_type !== 'schema-definition'
1805
+ (schema as { ui_type?: string } | undefined)?.ui_type !== 'schema-definition' &&
1806
+ getExpressionAst(optionField) === undefined
1604
1807
  ) ?
1605
1808
  getHashEntries(optionField, schema)
1606
1809
  : [];
@@ -1647,6 +1850,22 @@ export const CompactRow = memo(
1647
1850
  valueType === 'richtext' &&
1648
1851
  Array.isArray(optionField?.value) &&
1649
1852
  richtextToSegments(optionField.value as never).some((segment) => segment.kind === 'tag');
1853
+ /* The same pair, for a value that IS one template rather than prose with
1854
+ templates embedded in it. `showsTemplateChips` above closed the richtext
1855
+ case and left this one open: a chosen reference renders as
1856
+ `ReadOnlyTemplateTag`, which carries its own popover, while the row went
1857
+ on setting a native `title` holding the raw reference. Hovering fired
1858
+ both — two tooltips in two places, and the one that says something useful
1859
+ is not the one a screenshot captures. */
1860
+ const showsTemplateTag =
1861
+ !hidden &&
1862
+ typeof optionField?.value === 'string' &&
1863
+ !(optionField as { is_expression?: boolean } | undefined)?.is_expression &&
1864
+ (isValueTemplate(optionField.value) ||
1865
+ !!findTemplate(
1866
+ (schema as TFieldWithOwnTemplates | undefined)?.templates ?? templates ?? {},
1867
+ optionField.value
1868
+ ));
1650
1869
  // Markdown reads the same way for the same reason: the row itself can only
1651
1870
  // show a line of text, and for markdown that line is the SOURCE — the reader
1652
1871
  // gets `## ` and `**` where the point of the value is what it looks like
@@ -1960,6 +2179,7 @@ export const CompactRow = memo(
1960
2179
  !showCodePreview &&
1961
2180
  !showMarkdownPreview &&
1962
2181
  !showsTemplateChips &&
2182
+ !showsTemplateTag &&
1963
2183
  typeof formatted === 'string'
1964
2184
  ) ?
1965
2185
  formatted
@@ -140,7 +140,10 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
140
140
  const cWarning = intents.warning || '#d17c29';
141
141
  const cText = (theme.text?.color as string) || '#e8e8e8';
142
142
  const cTrack = `${cText}1f`;
143
- const setPct = completion.total ? (completion.set / completion.total) * 100 : 0;
143
+ // The GREEN run is what is done, not merely what has a value: a field that is
144
+ // set but flagged belongs to the amber run, and counting it twice pushed that
145
+ // run off the end of the track.
146
+ const setPct = completion.total ? (completion.done / completion.total) * 100 : 0;
144
147
  const attentionPct = completion.total ? (attentionCount / completion.total) * 100 : 0;
145
148
 
146
149
  return (
@@ -155,10 +158,14 @@ export const CompactToolbar = memo((reqoreProps: Partial<IReqoreControlGroupProp
155
158
  <StyledCompletionLine>
156
159
  <StyledSummary
157
160
  className='options-readfirst-status'
158
- $color={invalidCount ? cWarning : cSuccess}
161
+ $color={invalidCount || attentionCount ? cWarning : cSuccess}
159
162
  style={{ fontWeight: 600 }}
160
163
  >
161
- {invalidCount ? 'Draft' : 'Ready'}
164
+ {/* "Ready" is a claim about the whole form, so anything the form
165
+ is asking for withdraws it — a value that fails validation and
166
+ a field the host has flagged alike. Saying Ready beside
167
+ "1 need attention" made the word mean nothing. */}
168
+ {invalidCount || attentionCount ? 'Draft' : 'Ready'}
162
169
  </StyledSummary>
163
170
  <StyledSummary style={{ opacity: 0.5 }}>
164
171
  · {completion.set}/{completion.total} set