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

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 (283) 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/FORM_ENGINE_FIELD_MIGRATION.md +56 -1
  7. package/design/RENDER_EXPRESSION_TRANSPORT.md +76 -10
  8. package/design/SMART_EDITOR_UX.md +21 -0
  9. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  10. package/dist/components/dpqlEditor/DpqlEditor.js +3 -3
  11. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  12. package/dist/components/dpqlEditor/dpqlHelpers.d.ts +6 -0
  13. package/dist/components/dpqlEditor/dpqlHelpers.d.ts.map +1 -1
  14. package/dist/components/dpqlEditor/dpqlHelpers.js +21 -2
  15. package/dist/components/dpqlEditor/dpqlHelpers.js.map +1 -1
  16. package/dist/components/dpqlEditor/dpqlProbe.d.ts +37 -0
  17. package/dist/components/dpqlEditor/dpqlProbe.d.ts.map +1 -0
  18. package/dist/components/dpqlEditor/dpqlProbe.js +203 -0
  19. package/dist/components/dpqlEditor/dpqlProbe.js.map +1 -0
  20. package/dist/components/dpqlEditor/dpqlTags.d.ts +8 -0
  21. package/dist/components/dpqlEditor/dpqlTags.d.ts.map +1 -1
  22. package/dist/components/dpqlEditor/dpqlTags.js +5 -1
  23. package/dist/components/dpqlEditor/dpqlTags.js.map +1 -1
  24. package/dist/components/dpqlEditor/index.d.ts +4 -0
  25. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  26. package/dist/components/dpqlEditor/index.js +5 -1
  27. package/dist/components/dpqlEditor/index.js.map +1 -1
  28. package/dist/components/dpqlEditor/types.d.ts +42 -2
  29. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  30. package/dist/components/dpqlEditor/types.js +0 -4
  31. package/dist/components/dpqlEditor/types.js.map +1 -1
  32. package/dist/components/dpqlEditor/useDpqlProbe.d.ts +4 -0
  33. package/dist/components/dpqlEditor/useDpqlProbe.d.ts.map +1 -0
  34. package/dist/components/dpqlEditor/useDpqlProbe.js +29 -0
  35. package/dist/components/dpqlEditor/useDpqlProbe.js.map +1 -0
  36. package/dist/components/dpqlEditor/useDpqlSession.d.ts +5 -2
  37. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  38. package/dist/components/dpqlEditor/useDpqlSession.js +8 -2
  39. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  40. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  41. package/dist/components/form/engine/CompactRow.js +192 -45
  42. package/dist/components/form/engine/CompactRow.js.map +1 -1
  43. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  44. package/dist/components/form/engine/CompactToolbar.js +5 -2
  45. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  46. package/dist/components/form/engine/FormEngine.d.ts +160 -12
  47. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  48. package/dist/components/form/engine/FormEngine.js +376 -75
  49. package/dist/components/form/engine/FormEngine.js.map +1 -1
  50. package/dist/components/form/engine/FormFieldsSkeleton.d.ts +48 -0
  51. package/dist/components/form/engine/FormFieldsSkeleton.d.ts.map +1 -0
  52. package/dist/components/form/engine/FormFieldsSkeleton.js +38 -0
  53. package/dist/components/form/engine/FormFieldsSkeleton.js.map +1 -0
  54. package/dist/components/form/engine/OptionFieldMessages.d.ts +17 -2
  55. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  56. package/dist/components/form/engine/OptionFieldMessages.js +61 -11
  57. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  58. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  59. package/dist/components/form/engine/_structuredData/SchemaDataView.js +7 -1
  60. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  61. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  62. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  63. package/dist/components/form/engine/readFirst.d.ts +42 -1
  64. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  65. package/dist/components/form/engine/readFirst.js +74 -7
  66. package/dist/components/form/engine/readFirst.js.map +1 -1
  67. package/dist/components/form/engine/rendererTypes.d.ts +13 -0
  68. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -1
  69. package/dist/components/form/engine/rendererTypes.js.map +1 -1
  70. package/dist/components/form/engine/rowMenuContext.d.ts +78 -0
  71. package/dist/components/form/engine/rowMenuContext.d.ts.map +1 -0
  72. package/dist/components/form/engine/rowMenuContext.js +103 -0
  73. package/dist/components/form/engine/rowMenuContext.js.map +1 -0
  74. package/dist/components/form/engine/typeChoices.d.ts +37 -0
  75. package/dist/components/form/engine/typeChoices.d.ts.map +1 -0
  76. package/dist/components/form/engine/typeChoices.js +25 -0
  77. package/dist/components/form/engine/typeChoices.js.map +1 -0
  78. package/dist/components/form/expressions/DpqlRendering.d.ts +26 -0
  79. package/dist/components/form/expressions/DpqlRendering.d.ts.map +1 -0
  80. package/dist/components/form/expressions/DpqlRendering.js +53 -0
  81. package/dist/components/form/expressions/DpqlRendering.js.map +1 -0
  82. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  83. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  84. package/dist/components/form/expressions/ExpressionField.js +202 -45
  85. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  86. package/dist/components/form/expressions/argumentPresence.d.ts +97 -0
  87. package/dist/components/form/expressions/argumentPresence.d.ts.map +1 -0
  88. package/dist/components/form/expressions/argumentPresence.js +203 -0
  89. package/dist/components/form/expressions/argumentPresence.js.map +1 -0
  90. package/dist/components/form/expressions/builder/argumentLabel.d.ts.map +1 -1
  91. package/dist/components/form/expressions/builder/argumentLabel.js +9 -4
  92. package/dist/components/form/expressions/builder/argumentLabel.js.map +1 -1
  93. package/dist/components/form/expressions/builder/index.d.ts +15 -2
  94. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  95. package/dist/components/form/expressions/builder/index.js +20 -58
  96. package/dist/components/form/expressions/builder/index.js.map +1 -1
  97. package/dist/components/form/expressions/builder/renderTemplate.d.ts +4 -1
  98. package/dist/components/form/expressions/builder/renderTemplate.d.ts.map +1 -1
  99. package/dist/components/form/expressions/builder/renderTemplate.js +10 -67
  100. package/dist/components/form/expressions/builder/renderTemplate.js.map +1 -1
  101. package/dist/components/form/expressions/dpqlMockLanguage.d.ts +76 -0
  102. package/dist/components/form/expressions/dpqlMockLanguage.d.ts.map +1 -0
  103. package/dist/components/form/expressions/dpqlMockLanguage.js +525 -0
  104. package/dist/components/form/expressions/dpqlMockLanguage.js.map +1 -0
  105. package/dist/components/form/expressions/dpqlMockLsp.d.ts +31 -2
  106. package/dist/components/form/expressions/dpqlMockLsp.d.ts.map +1 -1
  107. package/dist/components/form/expressions/dpqlMockLsp.js +86 -80
  108. package/dist/components/form/expressions/dpqlMockLsp.js.map +1 -1
  109. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  110. package/dist/components/form/expressions/mockExpressions.js +8 -4
  111. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  112. package/dist/components/form/expressions/renderExpressionToText.d.ts +1 -1
  113. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  114. package/dist/components/form/expressions/renderExpressionToText.js +95 -19
  115. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  116. package/dist/components/form/expressions/types.d.ts +7 -0
  117. package/dist/components/form/expressions/types.d.ts.map +1 -1
  118. package/dist/components/form/expressions/useRenderExpression.d.ts +7 -16
  119. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  120. package/dist/components/form/expressions/useRenderExpression.js +50 -126
  121. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  122. package/dist/components/form/fields/Field.d.ts.map +1 -1
  123. package/dist/components/form/fields/Field.js +3 -1
  124. package/dist/components/form/fields/Field.js.map +1 -1
  125. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +11 -1
  126. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  127. package/dist/components/form/fields/allowed-values/AllowedValues.js +14 -2
  128. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  129. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  130. package/dist/components/form/fields/auto/AutoFormField.js +32 -8
  131. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  132. package/dist/components/form/fields/date/Date.d.ts +1 -1
  133. package/dist/components/form/fields/date/Date.d.ts.map +1 -1
  134. package/dist/components/form/fields/date/Date.js +16 -2
  135. package/dist/components/form/fields/date/Date.js.map +1 -1
  136. package/dist/components/form/fields/rich-text/RichText.d.ts +21 -1
  137. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  138. package/dist/components/form/fields/rich-text/RichText.js +140 -8
  139. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  140. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +2 -1
  141. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  142. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -2
  143. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  144. package/dist/components/form/fields/template/TemplateField.d.ts +31 -1
  145. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  146. package/dist/components/form/fields/template/TemplateField.js +528 -50
  147. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  148. package/dist/components/form/index.d.ts +1 -0
  149. package/dist/components/form/index.d.ts.map +1 -1
  150. package/dist/components/form/index.js +1 -0
  151. package/dist/components/form/index.js.map +1 -1
  152. package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
  153. package/dist/components/smartEditor/SmartEditor.js +29 -8
  154. package/dist/components/smartEditor/SmartEditor.js.map +1 -1
  155. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts +8 -0
  156. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts.map +1 -1
  157. package/dist/components/smartEditor/__fixtures__/mockLspServer.js +53 -10
  158. package/dist/components/smartEditor/__fixtures__/mockLspServer.js.map +1 -1
  159. package/dist/components/smartEditor/useLspSession.d.ts +5 -1
  160. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  161. package/dist/components/smartEditor/useLspSession.js +16 -2
  162. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  163. package/dist/helpers/dpqlDetection.d.ts +58 -0
  164. package/dist/helpers/dpqlDetection.d.ts.map +1 -0
  165. package/dist/helpers/dpqlDetection.js +164 -0
  166. package/dist/helpers/dpqlDetection.js.map +1 -0
  167. package/dist/helpers/optionUiTypes.d.ts +3 -3
  168. package/dist/helpers/options.d.ts +37 -0
  169. package/dist/helpers/options.d.ts.map +1 -1
  170. package/dist/helpers/options.js +78 -1
  171. package/dist/helpers/options.js.map +1 -1
  172. package/dist/helpers/templateItems.d.ts +47 -0
  173. package/dist/helpers/templateItems.d.ts.map +1 -0
  174. package/dist/helpers/templateItems.js +88 -0
  175. package/dist/helpers/templateItems.js.map +1 -0
  176. package/dist/helpers/templateText.d.ts +37 -0
  177. package/dist/helpers/templateText.d.ts.map +1 -0
  178. package/dist/helpers/templateText.js +64 -0
  179. package/dist/helpers/templateText.js.map +1 -0
  180. package/dist/helpers/templates.d.ts +19 -11
  181. package/dist/helpers/templates.d.ts.map +1 -1
  182. package/dist/helpers/templates.js +46 -29
  183. package/dist/helpers/templates.js.map +1 -1
  184. package/dist/helpers/validations.d.ts.map +1 -1
  185. package/dist/helpers/validations.js +56 -9
  186. package/dist/helpers/validations.js.map +1 -1
  187. package/dist/hooks/useArgSchema.d.ts +2 -1
  188. package/dist/hooks/useArgSchema.d.ts.map +1 -1
  189. package/dist/hooks/useFetch/useFetch.d.ts +3 -1
  190. package/dist/hooks/useFetch/useFetch.d.ts.map +1 -1
  191. package/dist/hooks/useFetch/useFetch.js +47 -9
  192. package/dist/hooks/useFetch/useFetch.js.map +1 -1
  193. package/dist/hooks/useTemplates.d.ts +20 -0
  194. package/dist/hooks/useTemplates.d.ts.map +1 -1
  195. package/dist/hooks/useTemplates.js +34 -2
  196. package/dist/hooks/useTemplates.js.map +1 -1
  197. package/dist/index.d.ts +2 -1
  198. package/dist/index.d.ts.map +1 -1
  199. package/dist/index.js +5 -1
  200. package/dist/index.js.map +1 -1
  201. package/dist/stories/storyNetwork.d.ts +36 -0
  202. package/dist/stories/storyNetwork.d.ts.map +1 -0
  203. package/dist/stories/storyNetwork.js +82 -0
  204. package/dist/stories/storyNetwork.js.map +1 -0
  205. package/dist/utils/fetch.d.ts +151 -1
  206. package/dist/utils/fetch.d.ts.map +1 -1
  207. package/dist/utils/fetch.js +256 -44
  208. package/dist/utils/fetch.js.map +1 -1
  209. package/dist/utils/lspClient.d.ts +6 -0
  210. package/dist/utils/lspClient.d.ts.map +1 -1
  211. package/dist/utils/lspClient.js +62 -11
  212. package/dist/utils/lspClient.js.map +1 -1
  213. package/dist/utils/websocket.d.ts +6 -0
  214. package/dist/utils/websocket.d.ts.map +1 -1
  215. package/dist/utils/websocket.js +15 -7
  216. package/dist/utils/websocket.js.map +1 -1
  217. package/package.json +2 -2
  218. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +3 -119
  219. package/src/components/dpqlEditor/DpqlEditor.tsx +5 -2
  220. package/src/components/dpqlEditor/dpqlHelpers.ts +20 -1
  221. package/src/components/dpqlEditor/dpqlProbe.ts +165 -0
  222. package/src/components/dpqlEditor/dpqlTags.ts +13 -0
  223. package/src/components/dpqlEditor/index.ts +4 -0
  224. package/src/components/dpqlEditor/types.ts +38 -2
  225. package/src/components/dpqlEditor/useDpqlProbe.ts +33 -0
  226. package/src/components/dpqlEditor/useDpqlSession.ts +20 -4
  227. package/src/components/form/engine/CompactRow.tsx +240 -51
  228. package/src/components/form/engine/CompactToolbar.tsx +10 -3
  229. package/src/components/form/engine/FormEngine.stories.tsx +423 -120
  230. package/src/components/form/engine/FormEngine.tsx +504 -87
  231. package/src/components/form/engine/FormFieldsSkeleton.tsx +76 -0
  232. package/src/components/form/engine/OptionFieldMessages.tsx +89 -11
  233. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +11 -0
  234. package/src/components/form/engine/compactToolbarContext.ts +1 -1
  235. package/src/components/form/engine/readFirst.ts +86 -7
  236. package/src/components/form/engine/rendererTypes.ts +12 -0
  237. package/src/components/form/engine/rowMenuContext.ts +159 -0
  238. package/src/components/form/engine/typeChoices.ts +55 -0
  239. package/src/components/form/expressions/DpqlRendering.tsx +85 -0
  240. package/src/components/form/expressions/ExpressionField.stories.tsx +826 -48
  241. package/src/components/form/expressions/ExpressionField.tsx +265 -54
  242. package/src/components/form/expressions/argumentPresence.ts +200 -0
  243. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +20 -4
  244. package/src/components/form/expressions/builder/argumentLabel.tsx +10 -4
  245. package/src/components/form/expressions/builder/index.tsx +39 -77
  246. package/src/components/form/expressions/builder/renderTemplate.tsx +23 -56
  247. package/src/components/form/expressions/dpqlMockLanguage.ts +565 -0
  248. package/src/components/form/expressions/dpqlMockLsp.ts +109 -83
  249. package/src/components/form/expressions/mockExpressions.ts +8 -4
  250. package/src/components/form/expressions/renderExpressionToText.ts +112 -22
  251. package/src/components/form/expressions/types.ts +7 -0
  252. package/src/components/form/expressions/useRenderExpression.ts +43 -127
  253. package/src/components/form/fields/Field.tsx +3 -2
  254. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -2
  255. package/src/components/form/fields/auto/AutoFormField.stories.tsx +27 -20
  256. package/src/components/form/fields/auto/AutoFormField.tsx +33 -9
  257. package/src/components/form/fields/date/Date.tsx +17 -1
  258. package/src/components/form/fields/rich-text/RichText.tsx +199 -22
  259. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -2
  260. package/src/components/form/fields/template/TemplateField.stories.tsx +31 -9
  261. package/src/components/form/fields/template/TemplateField.tsx +714 -122
  262. package/src/components/form/index.tsx +1 -0
  263. package/src/components/log/Log.stories.tsx +2 -1
  264. package/src/components/smartEditor/SmartEditor.tsx +41 -9
  265. package/src/components/smartEditor/__fixtures__/mockLspServer.ts +52 -3
  266. package/src/components/smartEditor/useLspSession.ts +21 -3
  267. package/src/helpers/dpqlDetection.ts +209 -0
  268. package/src/helpers/options.ts +70 -0
  269. package/src/helpers/templateItems.ts +81 -0
  270. package/src/helpers/templateText.ts +92 -0
  271. package/src/helpers/templates.ts +47 -37
  272. package/src/helpers/validations.ts +59 -9
  273. package/src/hooks/useFetch/useFetch.stories.tsx +6 -5
  274. package/src/hooks/useFetch/useFetch.tsx +53 -7
  275. package/src/hooks/useStorage/useStorage.stories.tsx +2 -1
  276. package/src/hooks/useTemplates.ts +41 -0
  277. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +2 -1
  278. package/src/index.tsx +14 -1
  279. package/src/stores/currentUser/currentUser.stories.tsx +2 -1
  280. package/src/stories/storyNetwork.ts +92 -0
  281. package/src/utils/fetch.ts +311 -21
  282. package/src/utils/lspClient.ts +68 -11
  283. package/src/utils/websocket.ts +20 -9
@@ -6,7 +6,6 @@ import {
6
6
  ReqoreIcon,
7
7
  ReqoreMessage,
8
8
  ReqoreP,
9
- ReqoreSkeleton,
10
9
  ReqoreTag,
11
10
  ReqoreTagGroup,
12
11
  ReqoreVerticalSpacer,
@@ -17,6 +16,7 @@ import { IReqoreCollectionProps } from '@qoretechnologies/reqore/dist/components
17
16
  import { IReqoreCollectionItemProps } from '@qoretechnologies/reqore/dist/components/Collection/item';
18
17
  import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel';
19
18
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
19
+ import { TFieldWithOwnTemplates } from './rendererTypes';
20
20
  import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
21
21
  import {
22
22
  changeDarkness,
@@ -38,6 +38,8 @@ import {
38
38
  TQorusFormOperatorValue,
39
39
  TQorusType,
40
40
  } from '@qoretechnologies/ts-toolkit';
41
+ import { shouldMarkAsExpression } from '../expressions/argumentPresence';
42
+ import { offersTypeChoices } from './typeChoices';
41
43
  import { resolveOptionActions, TOptionActions } from './optionActions';
42
44
  import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
43
45
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last } from 'lodash';
@@ -55,10 +57,14 @@ import {
55
57
  insertAtIndex,
56
58
  richtextToString,
57
59
  } from '../../../helpers/common';
58
- import { getRequiredOptionMessage } from '../../../helpers/options';
60
+ import {
61
+ getRequiredOptionMessage,
62
+ resolveDegenerateRequiredGroups,
63
+ } from '../../../helpers/options';
59
64
  import {
60
65
  IValidationResult,
61
66
  hasAllDependenciesFullfilled,
67
+ parseDependency,
62
68
  validateField,
63
69
  validateFieldWithResult,
64
70
  } from '../../../helpers/validations';
@@ -75,6 +81,7 @@ import {
75
81
  isValueTemplate,
76
82
  } from '../fields/template/TemplateField';
77
83
  import { CompactRow } from './CompactRow';
84
+ import { FormFieldsSkeleton } from './FormFieldsSkeleton';
78
85
  import { CompactRowContext, ICompactRowContext, TCodePreviewRenderer } from './compactRowContext';
79
86
  import {
80
87
  GROUP_INDENT,
@@ -257,9 +264,9 @@ const StyledCompactWrap = styled.div<{ $ownScroll?: boolean }>`
257
264
  toolbar silently stops pinning. \`clip\` keeps the horizontal guard without
258
265
  that coercion. */
259
266
  ${({ $ownScroll }) =>
260
- $ownScroll
261
- ? css`
262
- /* 'own': we are the scroller. The sticky toolbar pins to whatever
267
+ $ownScroll ?
268
+ css`
269
+ /* 'own': we are the scroller. The sticky toolbar pins to whatever
263
270
  scrolls, so a host whose scrollport carries top padding would
264
271
  resolve sticky \`top: 0\` against its padding box and leave an
265
272
  unblurred strip above the toolbar. Scrolling in this unpadded box
@@ -268,22 +275,22 @@ const StyledCompactWrap = styled.div<{ $ownScroll?: boolean }>`
268
275
 
269
276
  \`min-height: 0\` is what lets a flex item shrink below its content
270
277
  so there is something to scroll; it belongs to this branch only. */
271
- min-height: 0;
272
- max-height: 100%;
273
- overflow-y: auto;
274
- overflow-x: hidden;
275
- `
276
- : css`
277
- /* \`min-height: auto\` is load-bearing, not a default: inside a column
278
+ min-height: 0;
279
+ max-height: 100%;
280
+ overflow-y: auto;
281
+ overflow-x: hidden;
282
+ `
283
+ : css`
284
+ /* \`min-height: auto\` is load-bearing, not a default: inside a column
278
285
  flex parent the shrink allowance above collapses this box to zero
279
286
  height while its rows paint outside it, so the form looks right but
280
287
  contributes nothing to layout — the host cannot size or scroll to
281
288
  it and anything below it stacks against nothing. */
282
- min-height: auto;
283
- max-height: none;
284
- overflow-y: visible;
285
- overflow-x: clip;
286
- `}
289
+ min-height: auto;
290
+ max-height: none;
291
+ overflow-y: visible;
292
+ overflow-x: clip;
293
+ `}
287
294
 
288
295
  /* Option logos (e.g. language images) render as <img> inside ReqoreIcon's
289
296
  square box; constrain them so portrait PNGs don't overflow the row. */
@@ -355,17 +362,59 @@ const getOptionSchemaStorageType = (
355
362
  : isRendererOnly(option?.ui_type) ? option?.type || option?.ui_type
356
363
  : option?.ui_type || option?.type) || 'any') as TQorusType;
357
364
 
358
- const getOptionFieldStorageType = (
365
+ /**
366
+ * Whether an option currently holds an expression, in EITHER shape it comes in.
367
+ *
368
+ * At runtime the editor writes the flag onto the option itself
369
+ * (`{ type, is_expression: true, value }`), but a value loaded from a saved
370
+ * draft carries the envelope NESTED (`{ type, value: { is_expression: true,
371
+ * value } }`). Same thing, two shapes, and only the flat one was ever checked —
372
+ * so a freshly typed expression behaved while a reloaded one did not, which is
373
+ * exactly how this survived several fixes: every test used the runtime shape.
374
+ */
375
+ const optionHoldsExpression = (option: unknown): boolean => {
376
+ const o = option as { is_expression?: unknown; value?: { is_expression?: unknown } } | undefined;
377
+ return !!(o?.is_expression || o?.value?.is_expression);
378
+ };
379
+
380
+ /**
381
+ * Which type an option resolves to — the schema's, or the one stored on the
382
+ * value.
383
+ *
384
+ * Exported because this single decision is what every option field is rendered
385
+ * with, and what an expression's return type is checked against. It deserves
386
+ * to be assertable without standing up a form.
387
+ */
388
+ export const getOptionFieldStorageType = (
359
389
  optionName: string,
360
390
  fieldType: TQorusType | TQorusType[] | undefined,
361
391
  schema?: IQorusFormSchema,
362
392
  operators?: IOperatorsSchema,
363
393
  operatorData?: TOperatorValue,
364
- isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
394
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType,
395
+ /** Whether the stored value is an expression — see below. */
396
+ isExpression?: boolean
365
397
  ): TQorusType => {
366
398
  const schemaOption = schema?.[optionName];
399
+ /* A STORED type does not describe an expression.
400
+
401
+ An expression value is `{ is_expression: true, value: {...} }`, so the type
402
+ recorded beside it describes that envelope — `hash` — and not what the
403
+ field holds. Letting it win means the SCHEMA's declared type is ignored and
404
+ the wrong answer is handed to the expression editor as the return type to
405
+ check against: `1 + 2` on a field declared `"type": "auto"` came back
406
+ "The expression returns int, and this field holds hash". A literal `1` was
407
+ fine, because a literal is stored as a plain value whose type matches.
408
+
409
+ `auto`/`any` accept anything, which is exactly why nothing should be
410
+ checked against them; a concrete declared type still is. The schema is the
411
+ only party that knows what the field accepts. */
367
412
  const storedType =
368
- fieldType && !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType)) ?
413
+ (
414
+ !isExpression &&
415
+ fieldType &&
416
+ !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType))
417
+ ) ?
369
418
  fieldType
370
419
  : getOptionSchemaStorageType(schemaOption, isRendererOnly);
371
420
 
@@ -557,6 +606,85 @@ const normalizeEmptyFieldValues = (fields: TQorusForm | TQorusFlatForm | undefin
557
606
  {} as TQorusForm
558
607
  );
559
608
 
609
+ /**
610
+ * Does this rebuild say anything the parent does not already hold?
611
+ *
612
+ * `false` means the rebuild is a MIRROR: `fixOptions` found no default to
613
+ * materialise, so the result repeats the `value` the parent handed down. The
614
+ * compare is empty-normalized because `fixOptions` round-trips a required-empty
615
+ * field between `{ value: '' }` and no `value` key, and that cosmetic
616
+ * difference would otherwise read as new information forever.
617
+ */
618
+ export const isMirrorOfValue = (
619
+ fixedValue: TQorusForm | TQorusFlatForm | undefined,
620
+ value: TQorusForm | TQorusFlatForm | undefined
621
+ ): boolean => isEqual(normalizeEmptyFieldValues(fixedValue), normalizeEmptyFieldValues(value));
622
+
623
+ /**
624
+ * What this rebuild lets the form agree with the parent on, if anything.
625
+ *
626
+ * Returns the rebuild when it mirrors `value`, and `undefined` when it
627
+ * contributed something the parent has yet to learn.
628
+ *
629
+ * Adopting the parent's value IS agreement, and saying so matters as much as
630
+ * staying quiet about it. The sync-down skips only when the parent echoed what
631
+ * this form last sent; if adopting a value never updates that record, it points
632
+ * at an older state forever, the skip can never fire again, and the form
633
+ * rebuilds on every single change. Holding the mirror back WITHOUT recording
634
+ * the agreement was measured making the starvation worse, not better — 8 s
635
+ * became a 45 s timeout — which is why the two belong together.
636
+ */
637
+ export const agreementFromRebuild = (
638
+ fixedValue: TQorusForm | TQorusFlatForm | undefined,
639
+ value: TQorusForm | TQorusFlatForm | undefined
640
+ ): TQorusForm | TQorusFlatForm | undefined =>
641
+ isMirrorOfValue(fixedValue, value) ? fixedValue : undefined;
642
+
643
+ /** The state the emit decision is made against. */
644
+ export interface IEmitDecision {
645
+ /** The form's own copy of the value, as it stands now. */
646
+ localValue: TQorusForm | TQorusFlatForm | undefined;
647
+ /** The `value` prop, read at the moment the decision is made. */
648
+ value: TQorusForm | TQorusFlatForm | undefined;
649
+ /**
650
+ * The last rebuild that mirrored `value` without adding anything, if the most
651
+ * recent one did. `undefined` when the last rebuild contributed something.
652
+ */
653
+ mirroredValue: TQorusForm | TQorusFlatForm | undefined;
654
+ }
655
+
656
+ /**
657
+ * Should the form report `localValue` to its parent as an answer?
658
+ *
659
+ * Extracted and pure because the situation it exists for cannot be reproduced
660
+ * through a rendered form: it needs the emit to be decided against a `value`
661
+ * NEWER than the `localValue` built from it, which only happens when two
662
+ * components' effects land in one React commit — and a test renderer flushes
663
+ * effects between updates, so the two never fall out of step. The states below
664
+ * were recorded from a live instrumented run instead, and this is what makes
665
+ * them assertable.
666
+ *
667
+ * Two ways the answer is no:
668
+ *
669
+ * 1. `localValue` is a mirror — a rebuild of the parent's own value that added
670
+ * nothing. Repeating it back is not an answer. **Checked first**, because
671
+ * the comparison in (2) reads whatever `value` has become by now, which,
672
+ * when a nested engine shares the parent value, is already that engine's
673
+ * newer write; comparing against it would find a difference this form never
674
+ * made and emit the older mirror over the newer write. That is the loop.
675
+ * 2. `localValue` already equals `value` — there is nothing to report.
676
+ */
677
+ export const shouldEmitLocalValue = ({
678
+ localValue,
679
+ value,
680
+ mirroredValue,
681
+ }: IEmitDecision): boolean => {
682
+ if (mirroredValue !== undefined && isEqual(localValue, mirroredValue)) {
683
+ return false;
684
+ }
685
+ return !isEqual(localValue, value);
686
+ };
687
+
560
688
  export const flattenOptions = (options: TQorusForm): TQorusFlatForm => {
561
689
  return reduce(
562
690
  options,
@@ -653,6 +781,13 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
653
781
  recordRequiresSearchOptions?: boolean;
654
782
  readOnly?: boolean;
655
783
  allowTemplates?: boolean;
784
+ /**
785
+ * The form's SHARED template vocabulary — config items, system properties —
786
+ * offered to every field that supports templates.
787
+ *
788
+ * A field may also declare `templates` of its own in the schema, and that
789
+ * narrower list wins for that field: see {@link TFieldWithOwnTemplates}.
790
+ */
656
791
  stringTemplates?: IReqoreFormTemplates;
657
792
  /** Opt-in: fetch global templates from `system/getContextData` for this context. */
658
793
  interfaceContext?: string;
@@ -727,13 +862,29 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
727
862
  * the reader is choosing WHICH to open rather than reading this one, e.g. a
728
863
  * template's per-action option forms stacked in a drawer.
729
864
  *
730
- * Two escapes override this and are not negotiable: a box always opens while
731
- * a search query is running (`ReqorePanel` unmounts collapsed content, so a
732
- * match inside a closed box would be unreachable), and the Optional box stays
733
- * open when every row is optional, because collapsing it would render a card
734
- * that looks empty and broken.
865
+ * One escape is not negotiable: a box always opens while a search query is
866
+ * running (`ReqorePanel` unmounts collapsed content, so a match inside a
867
+ * closed box would be unreachable).
868
+ *
869
+ * A second applies only by DEFAULT: the Optional box stays open when every
870
+ * row is optional, because a form that collapsed its only box would render a
871
+ * card that looks empty and broken. Passing this prop explicitly waives it —
872
+ * a host that supplies its own heading and explanation around the form (a
873
+ * run-options panel, say) has already told the reader what the card is, and
874
+ * asked for the settings to start folded away.
735
875
  */
736
876
  compactCollapsedGroups?: TFormEngineBoxKey[];
877
+ /**
878
+ * Compact mode only: rename the status boxes.
879
+ *
880
+ * The three are `Needs attention`, `Set` and `Optional`, which read correctly
881
+ * when the form is the page's subject. Inside a host that already named the
882
+ * subject — a panel headed "Change a setting for this run" — a bare
883
+ * `Optional` names a category rather than the things in it, and
884
+ * `Optional settings` is what the reader is looking at. Only the labels
885
+ * change; bucketing, icons and intents are untouched.
886
+ */
887
+ compactBoxLabels?: Partial<Record<TFormEngineBoxKey, string>>;
737
888
  /**
738
889
  * Compact mode only: which parts of the form's toolbar to show. `true`
739
890
  * (default) shows all of it; `false` hides the whole thing.
@@ -781,6 +932,37 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
781
932
  * a consumer injecting many actions can never blow out the row.
782
933
  */
783
934
  optionActionsCollapse?: 'auto' | 'always' | 'never';
935
+ /**
936
+ * The HOST is still loading something this form needs.
937
+ *
938
+ * Declared because it was already read (`rest.skeleton`) and honoured by the
939
+ * loading gate, but undeclared props are invisible to the people who need
940
+ * them — and the reason to reach for this is not obvious.
941
+ *
942
+ * A host that resolves anything before it can render a form — an option list
943
+ * fetched by `get_message`, a schema id resolved over the wire — has a wait of
944
+ * its own that ends just as this component's begins. Drawing its own
945
+ * placeholder for that first half and then handing over produces TWO
946
+ * placeholders in series for one load, from two different components: the DOM
947
+ * node is replaced, the geometry can differ, and the page relayouts in the
948
+ * middle of waiting. Measured on the Qorus alert rule, whose three sections
949
+ * each did this — six placeholder mounts where three would do.
950
+ *
951
+ * Passing the host's own wait in here instead makes ONE component own the
952
+ * whole wait, from the host's first fetch to this form's last, with one
953
+ * placeholder and one DOM node.
954
+ */
955
+ skeleton?: boolean;
956
+ /**
957
+ * What the HOST is waiting for, when it hands its wait down via `skeleton`.
958
+ *
959
+ * Without it every host wait reads as the same word and the reading stops at
960
+ * the package boundary — measured on the live alert rule, three sections all
961
+ * reported `host` and the next step was guessing which of the host's own five
962
+ * conditions had fired. Surfaced as `data-wait` on the placeholder.
963
+ */
964
+ skeletonReason?: string;
965
+
784
966
  /**
785
967
  * SEAM (reqraft): consumer-injected field editors for types reqraft doesn't
786
968
  * ship (IDE domain fields). Keyed by field `type`/`ui_type`; forwarded through
@@ -817,6 +999,28 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
817
999
  * consumer's new editor no longer needs a reqraft release to be handled.
818
1000
  */
819
1001
  rendererOnlyUiTypes?: string[];
1002
+ /**
1003
+ * The `ui_type` names whose own editor renders templates as chips INLINE.
1004
+ *
1005
+ * For these the template selector is suppressed and the field's editor is
1006
+ * shown instead — the selector would replace a control the author can type in
1007
+ * with one they can only pick from. Merged with reqraft's built-in list
1008
+ * (`richtext`), the same way `rendererOnlyUiTypes` is.
1009
+ */
1010
+ templateAwareUiTypes?: string[];
1011
+ /**
1012
+ * Extra `ui_type` names that mean "a value of any type", for the purpose of
1013
+ * offering the per-type choices in the row's menu.
1014
+ *
1015
+ * A field that pins no concrete type offers "enter a value of type X" for
1016
+ * each type the form knows. That was gated on the schema saying
1017
+ * `ui_type: 'any'` alone, which misses two cases: an option that declares its
1018
+ * untypedness as `type: 'auto'` and carries no `ui_type` at all (the server
1019
+ * spells an assertion's Expected Value exactly that way), and an option whose
1020
+ * `ui_type` is a HOST's own untyped editor. Merged with reqraft's built-ins
1021
+ * (`any`, `auto`), the same way `templateAwareUiTypes` is.
1022
+ */
1023
+ anyLikeUiTypes?: string[];
820
1024
 
821
1025
  /**
822
1026
  * Bag of values forwarded from an outer FormEngine scope, used as a
@@ -878,12 +1082,17 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
878
1082
  * for the pane to exist at all — so a pasted or reloaded link lands on a
879
1083
  * collapsed form.
880
1084
  *
881
- * Applied once, on the first render where the schema has rows, so an
882
- * async-loaded schema is covered. It never re-expands a row the user has
883
- * since collapsed, and it does not participate in `expandMode: 'single'`
884
- * accordion collapsing — the caller is naming a starting point, not
885
- * driving the state. Only a remount re-arms it. No-op in classic
886
- * (non-compact) mode.
1085
+ * Applied once, on the first render that actually CONTAINS one of the named
1086
+ * rows — not merely the first render with any rows — so a schema arriving in
1087
+ * pieces still gets its address applied rather than spending the one shot on
1088
+ * a form the field had not reached yet.
1089
+ *
1090
+ * It never re-expands a row the user has since collapsed, and it does not
1091
+ * participate in `expandMode: 'single'` accordion collapsing — the caller is
1092
+ * naming a starting point, not driving the state. That holds against the
1093
+ * engine's own openers too: `expandFirstRequired` / `autoFocusFirstRequired`
1094
+ * open their target ALONGSIDE these rows in single mode instead of replacing
1095
+ * them. Only a remount re-arms it. No-op in classic (non-compact) mode.
887
1096
  */
888
1097
  initialExpandedOptions?: string[];
889
1098
 
@@ -941,7 +1150,8 @@ const FormEngineImpl = ({
941
1150
  compactNested = false,
942
1151
  compactScroll = 'host',
943
1152
  compactPanelProps,
944
- compactCollapsedGroups = ['optional'],
1153
+ compactCollapsedGroups: compactCollapsedGroupsProp,
1154
+ compactBoxLabels,
945
1155
  compactToolbar = true,
946
1156
  commitMode = 'immediate',
947
1157
  expandMode = 'single',
@@ -959,6 +1169,8 @@ const FormEngineImpl = ({
959
1169
  // onto a DOM node
960
1170
  markdownRenderer: _markdownRenderer, // eslint-disable-line @typescript-eslint/no-unused-vars
961
1171
  rendererOnlyUiTypes,
1172
+ templateAwareUiTypes,
1173
+ anyLikeUiTypes,
962
1174
  inheritedFromParent,
963
1175
  autoFocusFirstRequired,
964
1176
  expandFirstRequired,
@@ -987,7 +1199,17 @@ const FormEngineImpl = ({
987
1199
  optionActionsCollapse === 'always' ? true
988
1200
  : optionActionsCollapse === 'never' ? false
989
1201
  : !isHoverCapable || !!isMobile;
990
- const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
1202
+ /* The schema, as the form should ASK it — not always as it was served.
1203
+ `resolveDegenerateRequiredGroups` turns a one-of group that has a single
1204
+ member in this schema back into a plain required field, because a choice
1205
+ with one option is not a choice. Applied here, at the one place the schema
1206
+ enters the component, so the header, the chips, the messages, the meter and
1207
+ the read-first summary cannot disagree about it: every one of them reads
1208
+ `options`, and none of them has to know the rule. */
1209
+ const [servedOptions, setOptions] = useState<IQorusFormSchema | undefined>(
1210
+ rest?.options || undefined
1211
+ );
1212
+ const options = useMemo(() => resolveDegenerateRequiredGroups(servedOptions), [servedOptions]);
991
1213
  // optionsLoader lifecycle: loading feeds the skeleton gate, error the banner.
992
1214
  const [optionsLoading, setOptionsLoading] = useState<boolean>(!!optionsLoader && !rest?.options);
993
1215
  const [optionsError, setOptionsError] = useState<string | undefined>();
@@ -1198,6 +1420,32 @@ const FormEngineImpl = ({
1198
1420
  // Track the last value we emitted via onChange so we can skip re-applying fixOptions
1199
1421
  // when the parent echoes it back as the new value prop (controlled component loop prevention)
1200
1422
  const lastEmittedValue = useRef<TQorusForm | TQorusFlatForm | undefined>(value);
1423
+ /**
1424
+ * A `localValue` that is only a MIRROR of the incoming `value`, and so must
1425
+ * never be emitted back as though this form had answered something.
1426
+ *
1427
+ * `localValue` is derived state: the sync-down effect below rebuilds it from
1428
+ * the `value` prop. When that rebuild adds nothing (`fixOptions` had no
1429
+ * default to materialise), the result says exactly what the parent already
1430
+ * holds, and emitting it is not a change — it is this form repeating the
1431
+ * parent's own words back at it.
1432
+ *
1433
+ * That repetition is harmless in a form that owns its state outright and
1434
+ * fatal in one that shares a parent with a NESTED engine. The two write to
1435
+ * the same parent value, so each sees the other's write arrive as an external
1436
+ * change; `lastEmittedValue` is then always one step behind and its guard
1437
+ * never fires. The engines alternate between two states forever, several
1438
+ * times a second, and the newer write is repeatedly overwritten by the older
1439
+ * one. Measured in the test editor: the row's summary flipped between two
1440
+ * readings indefinitely and `setTimeout` was starved past a 45-second
1441
+ * deadline.
1442
+ *
1443
+ * Only a mirror that CHANGES nothing is held back. A rebuild that restores a
1444
+ * required or preselected field's default still emits, because that default
1445
+ * is genuinely new information the parent has to be told about — which is the
1446
+ * whole reason the sync-down runs `fixOptions` at all.
1447
+ */
1448
+ const mirroredValue = useRef<TQorusForm | TQorusFlatForm | undefined>(undefined);
1201
1449
 
1202
1450
  if (originalValue.current === undefined && size(value)) {
1203
1451
  originalValue.current = localValue.fields;
@@ -1208,7 +1456,13 @@ const FormEngineImpl = ({
1208
1456
  const templates = useTemplates(allowTemplates, rest.stringTemplates, interfaceContext);
1209
1457
 
1210
1458
  useEffect(() => {
1211
- if (isEqual(localValue.fields, value)) {
1459
+ if (
1460
+ !shouldEmitLocalValue({
1461
+ localValue: localValue.fields,
1462
+ value,
1463
+ mirroredValue: mirroredValue.current,
1464
+ })
1465
+ ) {
1212
1466
  return;
1213
1467
  }
1214
1468
 
@@ -1371,9 +1625,12 @@ const FormEngineImpl = ({
1371
1625
  // Note: compare fixedValue against value, not localValue.fields — localValue may have been
1372
1626
  // updated by nested FormEngine emissions, so comparing against it would never skip.
1373
1627
  const normalizedValue = normalizeEmptyFieldValues(value);
1628
+ /* What this rebuild lets us agree with the parent on — `undefined` when it
1629
+ contributed something the parent has yet to learn. */
1630
+ const agreed = agreementFromRebuild(fixedValue, value);
1374
1631
  if (
1375
1632
  isEqual(normalizedValue, normalizeEmptyFieldValues(lastEmittedValue.current)) &&
1376
- isEqual(normalizeEmptyFieldValues(fixedValue), normalizedValue)
1633
+ agreed !== undefined
1377
1634
  ) {
1378
1635
  return;
1379
1636
  }
@@ -1382,11 +1639,39 @@ const FormEngineImpl = ({
1382
1639
  originalValue.current = fixedValue;
1383
1640
  }
1384
1641
 
1642
+ /* Remember a mirror so the emit effect can tell one from an answer, and
1643
+ record the agreement it represents. A rebuild that DID add something
1644
+ leaves both alone — the parent has to learn about it. */
1645
+ mirroredValue.current = agreed;
1646
+ if (agreed !== undefined) {
1647
+ lastEmittedValue.current = agreed;
1648
+ }
1649
+
1385
1650
  setLocalValue?.({ fields: fixedValue, meta: undefined });
1386
1651
  }, [JSON.stringify(options), JSON.stringify(value), isRendererOnly]);
1387
1652
 
1653
+ /**
1654
+ * Fields the reader has actually edited in this instance.
1655
+ *
1656
+ * "This field is required" is an ERROR message, and an error is a report that
1657
+ * something went wrong. Under a field nobody has been in yet it reports
1658
+ * nothing — the form is empty because it is new — while the requirement is
1659
+ * already stated by the asterisk, by the Needs-attention box the row sits in,
1660
+ * and by the completion meter. A form whose first act is to accuse the reader
1661
+ * of a mistake they have not made teaches them to discount its warnings.
1662
+ *
1663
+ * So the message waits for a touch. Being IN the field and leaving it empty
1664
+ * is a real gap, and that one shows. A ref, not state: every edit re-renders
1665
+ * anyway, so the following render reads the current set with no second pass.
1666
+ */
1667
+ const touchedOptionsRef = useRef<Set<string>>(new Set());
1668
+
1388
1669
  const handleValueChange = useCallback(
1389
1670
  (optionName: string, val?: any, _type?: string, isFunction?: boolean) => {
1671
+ // Every route into this handler is a person acting: typing, picking from a
1672
+ // menu, clearing the value, adding an optional field. Marked here rather
1673
+ // than inside the updater below, which React may invoke twice.
1674
+ touchedOptionsRef.current.add(optionName);
1390
1675
  setLocalValue(({ fields = {} }) => {
1391
1676
  const schemaType = getOptionSchemaStorageType(options?.[optionName], isRendererOnly);
1392
1677
  const isAnyLike = schemaType === 'any' || schemaType === 'auto';
@@ -1441,7 +1726,19 @@ const FormEngineImpl = ({
1441
1726
  },
1442
1727
  };
1443
1728
 
1444
- if (isFunction) {
1729
+ /* The flag belongs to the VALUE, so a change that said nothing about it
1730
+ does not throw it away — `undefined` is no opinion, not a denial.
1731
+ Deleting it unconditionally left the AST on the row with nothing
1732
+ marking it as one: an assertion's Expected Value came back from a
1733
+ saved draft drawn as a two-field hash of `exp` and `args`, because
1734
+ merely opening the row and reverting had stripped the flag. */
1735
+ if (
1736
+ shouldMarkAsExpression(
1737
+ isFunction,
1738
+ val,
1739
+ ((fields as TQorusForm)[optionName] as { is_expression?: boolean })?.is_expression
1740
+ )
1741
+ ) {
1445
1742
  (updatedValue[optionName] as { is_expression?: boolean }).is_expression = true;
1446
1743
  } else {
1447
1744
  delete updatedValue[optionName].is_expression;
@@ -1455,9 +1752,24 @@ const FormEngineImpl = ({
1455
1752
  val !== (fields as TQorusForm)[optionName]?.value
1456
1753
  ) {
1457
1754
  forEach(options, (option, depName) => {
1755
+ /* A dependency is `name`, `name=value` or `name!=value`, so the
1756
+ entry has to be PARSED to find which option it names. Compared as
1757
+ a whole string, only the bare spelling ever matched — so a field
1758
+ declared `depends_on: ['subject_iface_kind=workflow']` was never
1759
+ cleared when that option changed.
1760
+
1761
+ What that cost: a test whose subject was switched from a workflow
1762
+ to a service kept the workflow's `Subject Interface Version`.
1763
+ Services and jobs are versioned but only the latest is testable,
1764
+ so the field no longer applied, yet it stayed on the form as a
1765
+ locked control in front of a value the author could not clear —
1766
+ the value being there is exactly what stops the field being
1767
+ withheld outright. */
1458
1768
  if (
1459
1769
  option.depends_on &&
1460
- flatten(option.depends_on).includes(optionName) &&
1770
+ flatten(option.depends_on).some(
1771
+ (dependency) => parseDependency(dependency as string).name === optionName
1772
+ ) &&
1461
1773
  updatedValue[depName]
1462
1774
  ) {
1463
1775
  updatedValue[depName].value = undefined;
@@ -1637,6 +1949,7 @@ const FormEngineImpl = ({
1637
1949
  return newValue;
1638
1950
  }
1639
1951
 
1952
+
1640
1953
  const rendererType = getType(
1641
1954
  (options[optionName].ui_type || options[optionName].type) as TQorusType,
1642
1955
  operators,
@@ -1648,7 +1961,8 @@ const FormEngineImpl = ({
1648
1961
  options,
1649
1962
  operators,
1650
1963
  (option as IQorusFormField)?.op,
1651
- isRendererOnly
1964
+ isRendererOnly,
1965
+ optionHoldsExpression(option)
1652
1966
  );
1653
1967
 
1654
1968
  if (!isPlainObject(option)) {
@@ -1793,7 +2107,8 @@ const FormEngineImpl = ({
1793
2107
  options,
1794
2108
  operators,
1795
2109
  (option as IQorusFormField).op,
1796
- isRendererOnly
2110
+ isRendererOnly,
2111
+ optionHoldsExpression(option)
1797
2112
  );
1798
2113
  const optionValue = (option as IQorusFormField).value;
1799
2114
 
@@ -1864,7 +2179,8 @@ const FormEngineImpl = ({
1864
2179
  options,
1865
2180
  operators,
1866
2181
  (option as IQorusFormField).op,
1867
- isRendererOnly
2182
+ isRendererOnly,
2183
+ optionHoldsExpression(option)
1868
2184
  ),
1869
2185
  (option as IQorusFormField).value
1870
2186
  )
@@ -1913,7 +2229,8 @@ const FormEngineImpl = ({
1913
2229
  options,
1914
2230
  operators,
1915
2231
  (availableOptions as TQorusForm)?.[name]?.op,
1916
- isRendererOnly
2232
+ isRendererOnly,
2233
+ optionHoldsExpression((availableOptions as TQorusForm)?.[name])
1917
2234
  );
1918
2235
  const value = (availableOptions as TQorusForm)?.[name]?.value;
1919
2236
  // A schema-declared hash whose every entry is a materialised-but-unset
@@ -2064,11 +2381,16 @@ const FormEngineImpl = ({
2064
2381
  if (!names.length) {
2065
2382
  return;
2066
2383
  }
2067
- hasAppliedInitialExpansionRef.current = true;
2068
2384
  const toExpand = initialExpandedOptions.filter((name) => names.includes(name));
2069
2385
  if (!toExpand.length) {
2386
+ // Not "there is no such row" — "not YET". A server-driven schema arrives
2387
+ // in pieces, and the row the caller named can be in a later one (a test's
2388
+ // `cases` field materialises once its step-kind catalogue lands). Latching
2389
+ // here would spend the one shot on a form that did not yet contain the
2390
+ // field, and the address would never be applied at all.
2070
2391
  return;
2071
2392
  }
2393
+ hasAppliedInitialExpansionRef.current = true;
2072
2394
  setExpandedOptions((prev) => [...prev, ...toExpand.filter((name) => !prev.includes(name))]);
2073
2395
  }, [initialExpandedOptions, compact, availableOptions]);
2074
2396
 
@@ -2159,7 +2481,13 @@ const FormEngineImpl = ({
2159
2481
  setExpandedOptions((prev) =>
2160
2482
  prev.includes(target) ? prev
2161
2483
  : expandMode === 'multi' ? [...prev, target]
2162
- : [target]
2484
+ // `expandMode: 'single'` is a rule about what a CLICK does: opening a
2485
+ // row closes the one the reader last opened. It is not a licence to
2486
+ // close a row the HOST named through `initialExpandedOptions`. That
2487
+ // is an address — a statement about what the page is FOR, made before
2488
+ // the reader did anything — and this scan is a convenience layered on
2489
+ // top of it. Keep the named rows; replace only what a click could.
2490
+ : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
2163
2491
  );
2164
2492
  }
2165
2493
  }, [
@@ -2172,13 +2500,18 @@ const FormEngineImpl = ({
2172
2500
  dependencyLockedNames,
2173
2501
  getOptionBucket,
2174
2502
  expandMode,
2503
+ initialExpandedOptions,
2175
2504
  ]);
2176
2505
 
2177
2506
  // Read-first completion summary (how many shown options have a value set),
2178
2507
  // surfaced as a progress meter at the top of the compact form.
2179
2508
  const readFirstCompletion = useMemo(
2180
- () => getReadFirstCompletion(availableOptions as Record<string, IQorusFormField | undefined>),
2181
- [JSON.stringify(availableOptions)]
2509
+ () =>
2510
+ getReadFirstCompletion(
2511
+ availableOptions as Record<string, IQorusFormField | undefined>,
2512
+ (name) => getOptionBucket(name) === 'attention'
2513
+ ),
2514
+ [JSON.stringify(availableOptions), getOptionBucket]
2182
2515
  );
2183
2516
 
2184
2517
  const optionalFields = useMemo(
@@ -2273,12 +2606,58 @@ const FormEngineImpl = ({
2273
2606
  const optionSchema = options?.[optionName];
2274
2607
  const schemaUiType = optionSchema?.ui_type as TQorusType;
2275
2608
  const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
2609
+ /* An EXPRESSION's stored type is skipped in this chain.
2610
+
2611
+ The value is `{ is_expression: true, value: {...} }`, so the type saved
2612
+ beside it describes that envelope — `hash` — not what the field holds.
2613
+ With no `ui_type` to win first, that `hash` beat the schema's `auto`
2614
+ and was handed to the expression editor as the return type to check
2615
+ against: `1 + 2` on a field declared `auto` came back "This does not
2616
+ fit. The expression returns int, and this field holds hash". A literal
2617
+ `1` was fine, because a literal is stored as a plain value whose type
2618
+ matches.
2619
+
2620
+ Dropping the stored type here lets the schema decide, which is the
2621
+ only party that knows what the field accepts. This is the row
2622
+ renderer's OWN resolution and does not go through
2623
+ `getOptionFieldStorageType`, so guarding that one alone left this
2624
+ untouched. */
2625
+ const storedRowType = optionHoldsExpression(other) ? undefined : type;
2626
+
2627
+ /* A RELOADED expression is handed to the editor in the shape the editor
2628
+ was written against.
2629
+
2630
+ An expression is written as `{ type, is_expression: true, value: ast }`
2631
+ and saved as `{ type, value: { is_expression: true, value: ast } }` —
2632
+ the flag moves ONTO the value. The editor was only ever given
2633
+ `other.is_expression`, which is absent in the saved shape, so a
2634
+ reloaded expression was not recognised as one: an `auto` field then
2635
+ resolved its type from the value it could see, decided the envelope was
2636
+ a `hash`, and rendered `1 + 2` as a raw tree of `is_expression` /
2637
+ `exp` / `args` for the author to edit by hand.
2638
+
2639
+ Both halves have to move together. Flipping the flag alone would put
2640
+ the editor into expression mode and then hand it the ENVELOPE where it
2641
+ expects the expression, so the value is unwrapped here too — after
2642
+ which a saved expression is indistinguishable from one just typed,
2643
+ which is what every component downstream already assumes.
2644
+
2645
+ Reported as "after Run assertion the value shows as raw data", but the
2646
+ run is incidental: the field beside it, which no run touched, was
2647
+ equally raw. The trigger is a RELOAD, which is the only thing that puts
2648
+ an expression into its saved shape. */
2649
+ const nestedExpression = !!(other as { value?: { is_expression?: unknown } })?.value
2650
+ ?.is_expression;
2651
+ const expressionAwareValue =
2652
+ nestedExpression ?
2653
+ (other as unknown as { value: { value: unknown } }).value.value
2654
+ : other.value;
2276
2655
  const resolvedType =
2277
2656
  (isFixedCompactAllowedValueOption(optionSchema) ?
2278
2657
  getOptionSchemaStorageType(optionSchema, isRendererOnly)
2279
2658
  : schemaUiType && !uiTypeIsAnyLike ? schemaUiType
2280
2659
  : undefined) ||
2281
- type ||
2660
+ storedRowType ||
2282
2661
  schemaUiType ||
2283
2662
  (optionSchema?.type as TQorusType) ||
2284
2663
  'any';
@@ -2425,6 +2804,7 @@ const FormEngineImpl = ({
2425
2804
  // SEAM: forwarded through TemplateField's rest-spread to AutoFormField,
2426
2805
  // which renders consumer-injected editors by field type/ui_type.
2427
2806
  componentOverrides={componentOverrides}
2807
+ templateAwareUiTypes={templateAwareUiTypes}
2428
2808
  allowTemplates={!!(allowTemplates && options?.[optionName]?.supports_templates)}
2429
2809
  allowFunctions={!!options?.[optionName]?.supports_expressions}
2430
2810
  // reqraft: form-level expression fields get the Visual/Text shell
@@ -2441,7 +2821,25 @@ const FormEngineImpl = ({
2441
2821
  isFixedCompactAllowedValueOption(optionSchema) ||
2442
2822
  (options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any')
2443
2823
  }
2444
- templates={templates.value}
2824
+ // A field may declare a template list OF ITS OWN, and when it does
2825
+ // that list is the one to offer. The engine-wide `stringTemplates`
2826
+ // are the form's shared vocabulary - config items, system properties
2827
+ // - and every field draws on the same set. A per-field list answers a
2828
+ // question only that field asks: a test assertion's `$.` references
2829
+ // are the values ITS case captured, and no other field in the form
2830
+ // should be offering them.
2831
+ //
2832
+ // Passing the engine's list unconditionally overwrote the schema's,
2833
+ // so a field that declared templates had none by the time it
2834
+ // rendered. On a typed field that only cost the picker; on an
2835
+ // ANY-LIKE one it changed the question, because `TemplateField`
2836
+ // opens on the template selector only when there is something to
2837
+ // pick and otherwise falls back to the type picker. An untyped field
2838
+ // therefore asked the author to choose `string`/`int`/`hash` before
2839
+ // it would let them name a value they had already captured.
2840
+ templates={
2841
+ (optionSchema as TFieldWithOwnTemplates | undefined)?.templates ?? templates.value
2842
+ }
2445
2843
  {...getTypeAndCanBeNull(
2446
2844
  // The RENDERER type picks the editor — the storage type lives on
2447
2845
  // the value envelope. Passing storage here rendered a `richtext`
@@ -2462,8 +2860,8 @@ const FormEngineImpl = ({
2462
2860
  key={optionName}
2463
2861
  arg_schema={options?.[optionName]?.arg_schema}
2464
2862
  noSoft={!!rest?.options}
2465
- value={other.value}
2466
- isFunction={(other as { is_expression?: boolean }).is_expression}
2863
+ value={expressionAwareValue}
2864
+ isFunction={optionHoldsExpression(other)}
2467
2865
  isDefaultFunction={options?.[optionName]?.default_view === 'expression'}
2468
2866
  sensitive={options?.[optionName]?.sensitive}
2469
2867
  default_value={getDefaultValue(options?.[optionName])}
@@ -2487,7 +2885,7 @@ const FormEngineImpl = ({
2487
2885
  readOnly={readOnly}
2488
2886
  size={editorSize || rest.size}
2489
2887
  menuItems={
2490
- (options?.[optionName] as any)?.ui_type === 'any' ?
2888
+ offersTypeChoices(options?.[optionName], anyLikeUiTypes) ?
2491
2889
  getCustomMenuTemplateItems(optionName)
2492
2890
  : undefined
2493
2891
  }
@@ -2500,6 +2898,7 @@ const FormEngineImpl = ({
2500
2898
  name={optionName}
2501
2899
  option={{ type: resolvedType, ...other }}
2502
2900
  getType={getTypeForOption}
2901
+ untouched={!touchedOptionsRef.current.has(optionName)}
2503
2902
  />
2504
2903
  {operators && size(operators) && size(other.op) ?
2505
2904
  <>
@@ -2698,7 +3097,21 @@ const FormEngineImpl = ({
2698
3097
  fields: false,
2699
3098
  help: false,
2700
3099
  },
2701
- hasMultipleOptions: size(availableOptions) > 1,
3100
+ /* From the SCHEMA, not from the value.
3101
+ *
3102
+ * `availableOptions` is keyed off the form's VALUE, so it grows as data
3103
+ * arrives — and the search row is rendered only when this is true
3104
+ * (`CompactToolbar`). A form whose value lands after its first render
3105
+ * therefore ADDED a 38px row to its header a beat later, and everything
3106
+ * below it moved. Measured on the Qorus IDE's test editor, on the nested
3107
+ * sub-form of a case: 33 elements jumped down 51px in one frame, on every
3108
+ * cold load.
3109
+ *
3110
+ * How many fields a form has is a property of its schema. The two agree
3111
+ * once the value has arrived — `fixOptions` materialises an entry per
3112
+ * declared field — so this only changes the answer during the load, which
3113
+ * is the whole point. */
3114
+ hasMultipleOptions: size(options) > 1,
2702
3115
  compactQuery,
2703
3116
  setCompactQuery,
2704
3117
  requiredOnly,
@@ -2949,6 +3362,13 @@ const FormEngineImpl = ({
2949
3362
  const bucketCount = (b: TFormEngineBoxKey) =>
2950
3363
  bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2951
3364
 
3365
+ /* The default is to collapse the Optional box, and an explicit pass is
3366
+ what waives the "never collapse the sole box" escape below. Resolving it
3367
+ here rather than in the parameter list is what makes the two
3368
+ distinguishable — `['optional']` as a default and `['optional']` from a
3369
+ caller mean different things. */
3370
+ const compactCollapsedGroups = compactCollapsedGroupsProp ?? ['optional'];
3371
+ const collapsedGroupsRequested = compactCollapsedGroupsProp !== undefined;
2952
3372
  // "Is the Optional box the whole form?" — when nothing needs attention and
2953
3373
  // nothing is set, collapsing it leaves a card with no visible content at all.
2954
3374
  const onlyOptionalRows =
@@ -2991,9 +3411,18 @@ const FormEngineImpl = ({
2991
3411
  intent?: 'warning' | 'success';
2992
3412
  icon: IReqoreIconName;
2993
3413
  }> = [
2994
- { key: 'attention', label: 'Needs attention', intent: 'warning', icon: 'ErrorWarningLine' },
2995
- { key: 'set', label: 'Set', intent: 'success', icon: 'CheckLine' },
2996
- { key: 'optional', label: 'Optional', icon: 'CheckboxBlankCircleLine' },
3414
+ {
3415
+ key: 'attention',
3416
+ label: compactBoxLabels?.attention ?? 'Needs attention',
3417
+ intent: 'warning',
3418
+ icon: 'ErrorWarningLine',
3419
+ },
3420
+ { key: 'set', label: compactBoxLabels?.set ?? 'Set', intent: 'success', icon: 'CheckLine' },
3421
+ {
3422
+ key: 'optional',
3423
+ label: compactBoxLabels?.optional ?? 'Optional',
3424
+ icon: 'CheckboxBlankCircleLine',
3425
+ },
2997
3426
  ];
2998
3427
 
2999
3428
  // Build the rows for one group: contiguous required-group members are pulled
@@ -3137,7 +3566,7 @@ const FormEngineImpl = ({
3137
3566
  // Mirrors the toolbar's own search-row gate: when no search
3138
3567
  // row renders, the header is a thin strip and sits tight to
3139
3568
  // the first status box (see StyledCompactPanel).
3140
- $tightHeader={!(compactToolbarParts?.search && size(availableOptions) > 1)}
3569
+ $tightHeader={!(compactToolbarParts?.search && size(options) > 1)}
3141
3570
  // The top-level form scrolls, so its toolbar STICKS and carries a
3142
3571
  // dark blurred backdrop so content ghosts cleanly beneath it. A
3143
3572
  // nested (arg_schema) sub-form owns no scroll context — drop the
@@ -3205,7 +3634,7 @@ const FormEngineImpl = ({
3205
3634
  const wouldCollapse =
3206
3635
  compactCollapsedGroups.includes(box.key) &&
3207
3636
  !query &&
3208
- !(box.key === 'optional' && onlyOptionalRows);
3637
+ !(box.key === 'optional' && onlyOptionalRows && !collapsedGroupsRequested);
3209
3638
  // Only a box that was opened SOLELY to reveal a preselected
3210
3639
  // row splits its body; a box that renders open anyway shows
3211
3640
  // all of its groups as before.
@@ -3446,37 +3875,25 @@ const FormEngineImpl = ({
3446
3875
  );
3447
3876
  };
3448
3877
 
3449
- if (
3450
- rest.skeleton ||
3451
- templates.loading ||
3452
- typesLoading ||
3453
- optionsLoading ||
3454
- // Remote-fetch gates, mirroring IDE Options (systemOptions.tsx:1097-1102).
3455
- loading ||
3456
- (operatorsUrl && !operators) ||
3457
- ((url || customUrl) && !options)
3458
- ) {
3459
- return (
3460
- <ReqoreControlGroup
3461
- className='options-loading-skeleton'
3462
- vertical
3463
- fill
3464
- fluid
3465
- style={{ flexGrow: 1 }}
3466
- gapSize='big'
3467
- >
3468
- <ReqoreControlGroup fixed fill={false}>
3469
- <ReqoreSkeleton />
3470
- <ReqoreSkeleton />
3471
- <ReqoreSkeleton width='100%' />
3472
- </ReqoreControlGroup>
3473
- <ReqoreControlGroup vertical fill={false}>
3474
- <ReqoreSkeleton width='100%' height='150px' />
3475
- <ReqoreSkeleton width='100%' height='150px' />
3476
- <ReqoreSkeleton width='100%' height='150px' />
3477
- </ReqoreControlGroup>
3478
- </ReqoreControlGroup>
3479
- );
3878
+ /* Which of the seven waits is holding the form, in the order they are
3879
+ tested. Rendered as `data-wait` on the placeholder so a page can be asked
3880
+ what it is waiting FOR instead of only that it is waiting. */
3881
+ const waitReason =
3882
+ rest.skeleton ? (rest.skeletonReason ? `host:${rest.skeletonReason}` : 'host')
3883
+ : templates.loading ? 'templates'
3884
+ : typesLoading ? 'types'
3885
+ : optionsLoading ? 'options'
3886
+ : loading ? 'fetch'
3887
+ : operatorsUrl && !operators ? 'operators'
3888
+ : (url || customUrl) && !options ? 'schema'
3889
+ : undefined;
3890
+
3891
+ if (waitReason) {
3892
+ /* The shape of the FORM, not a generic block arrangement. This used to be
3893
+ three bars over three big panels, which resembles nothing this gate is
3894
+ waiting for and shared no vocabulary with the other waits on the same
3895
+ page — see `FormFieldsSkeleton`. */
3896
+ return <FormFieldsSkeleton fill className='options-loading-skeleton' reason={waitReason} />;
3480
3897
  }
3481
3898
 
3482
3899
  // A loader that rejected (and produced no usable schema) surfaces its error