@qoretechnologies/reqraft 0.10.54 → 0.10.55-pr.115.g37abdfc

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 (370) 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_COMPACT.md +34 -0
  6. package/design/FORM_ENGINE_EXPRESSIONS.md +110 -0
  7. package/design/FORM_ENGINE_FIELD_MIGRATION.md +155 -1
  8. package/design/IDE_INTEGRATION.md +13 -0
  9. package/design/RENDER_EXPRESSION_TRANSPORT.md +76 -10
  10. package/design/SELECT_DIALOG_FLOOR.md +86 -0
  11. package/design/SMART_EDITOR_UX.md +21 -0
  12. package/design/UNAVAILABLE_ALLOWED_VALUES.md +226 -0
  13. package/dist/components/composer/Composer.d.ts +8 -3
  14. package/dist/components/composer/Composer.d.ts.map +1 -1
  15. package/dist/components/composer/Composer.js.map +1 -1
  16. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  17. package/dist/components/dpqlEditor/DpqlEditor.js +3 -3
  18. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  19. package/dist/components/dpqlEditor/dpqlHelpers.d.ts +6 -0
  20. package/dist/components/dpqlEditor/dpqlHelpers.d.ts.map +1 -1
  21. package/dist/components/dpqlEditor/dpqlHelpers.js +21 -2
  22. package/dist/components/dpqlEditor/dpqlHelpers.js.map +1 -1
  23. package/dist/components/dpqlEditor/dpqlProbe.d.ts +37 -0
  24. package/dist/components/dpqlEditor/dpqlProbe.d.ts.map +1 -0
  25. package/dist/components/dpqlEditor/dpqlProbe.js +203 -0
  26. package/dist/components/dpqlEditor/dpqlProbe.js.map +1 -0
  27. package/dist/components/dpqlEditor/dpqlTags.d.ts +8 -0
  28. package/dist/components/dpqlEditor/dpqlTags.d.ts.map +1 -1
  29. package/dist/components/dpqlEditor/dpqlTags.js +5 -1
  30. package/dist/components/dpqlEditor/dpqlTags.js.map +1 -1
  31. package/dist/components/dpqlEditor/index.d.ts +4 -0
  32. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  33. package/dist/components/dpqlEditor/index.js +5 -1
  34. package/dist/components/dpqlEditor/index.js.map +1 -1
  35. package/dist/components/dpqlEditor/types.d.ts +42 -2
  36. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  37. package/dist/components/dpqlEditor/types.js +0 -4
  38. package/dist/components/dpqlEditor/types.js.map +1 -1
  39. package/dist/components/dpqlEditor/useDpqlProbe.d.ts +4 -0
  40. package/dist/components/dpqlEditor/useDpqlProbe.d.ts.map +1 -0
  41. package/dist/components/dpqlEditor/useDpqlProbe.js +29 -0
  42. package/dist/components/dpqlEditor/useDpqlProbe.js.map +1 -0
  43. package/dist/components/dpqlEditor/useDpqlSession.d.ts +5 -2
  44. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  45. package/dist/components/dpqlEditor/useDpqlSession.js +8 -2
  46. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  47. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  48. package/dist/components/form/engine/CompactRow.js +414 -128
  49. package/dist/components/form/engine/CompactRow.js.map +1 -1
  50. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  51. package/dist/components/form/engine/CompactToolbar.js +5 -2
  52. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  53. package/dist/components/form/engine/FormEngine.d.ts +272 -19
  54. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  55. package/dist/components/form/engine/FormEngine.js +666 -161
  56. package/dist/components/form/engine/FormEngine.js.map +1 -1
  57. package/dist/components/form/engine/FormFieldsSkeleton.d.ts +48 -0
  58. package/dist/components/form/engine/FormFieldsSkeleton.d.ts.map +1 -0
  59. package/dist/components/form/engine/FormFieldsSkeleton.js +41 -0
  60. package/dist/components/form/engine/FormFieldsSkeleton.js.map +1 -0
  61. package/dist/components/form/engine/OptionFieldMessages.d.ts +108 -2
  62. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  63. package/dist/components/form/engine/OptionFieldMessages.js +203 -13
  64. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  65. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  66. package/dist/components/form/engine/_structuredData/SchemaDataView.js +68 -5
  67. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  68. package/dist/components/form/engine/compactRowContext.d.ts +15 -1
  69. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  70. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  71. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  72. package/dist/components/form/engine/compactRowStyles.js +55 -29
  73. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  74. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  75. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  76. package/dist/components/form/engine/optionActions.d.ts +37 -1
  77. package/dist/components/form/engine/optionActions.d.ts.map +1 -1
  78. package/dist/components/form/engine/optionActions.js +71 -1
  79. package/dist/components/form/engine/optionActions.js.map +1 -1
  80. package/dist/components/form/engine/optionsContext.d.ts +22 -0
  81. package/dist/components/form/engine/optionsContext.d.ts.map +1 -0
  82. package/dist/components/form/engine/optionsContext.js +20 -0
  83. package/dist/components/form/engine/optionsContext.js.map +1 -0
  84. package/dist/components/form/engine/readFirst.d.ts +42 -1
  85. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  86. package/dist/components/form/engine/readFirst.js +76 -8
  87. package/dist/components/form/engine/readFirst.js.map +1 -1
  88. package/dist/components/form/engine/rendererTypes.d.ts +42 -3
  89. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -1
  90. package/dist/components/form/engine/rendererTypes.js +3 -1
  91. package/dist/components/form/engine/rendererTypes.js.map +1 -1
  92. package/dist/components/form/engine/rowFocus.d.ts +30 -0
  93. package/dist/components/form/engine/rowFocus.d.ts.map +1 -0
  94. package/dist/components/form/engine/rowFocus.js +93 -0
  95. package/dist/components/form/engine/rowFocus.js.map +1 -0
  96. package/dist/components/form/engine/rowMenuContext.d.ts +78 -0
  97. package/dist/components/form/engine/rowMenuContext.d.ts.map +1 -0
  98. package/dist/components/form/engine/rowMenuContext.js +103 -0
  99. package/dist/components/form/engine/rowMenuContext.js.map +1 -0
  100. package/dist/components/form/engine/rowOpenPicker.d.ts +27 -0
  101. package/dist/components/form/engine/rowOpenPicker.d.ts.map +1 -0
  102. package/dist/components/form/engine/rowOpenPicker.js +30 -0
  103. package/dist/components/form/engine/rowOpenPicker.js.map +1 -0
  104. package/dist/components/form/engine/typeChoices.d.ts +42 -0
  105. package/dist/components/form/engine/typeChoices.d.ts.map +1 -0
  106. package/dist/components/form/engine/typeChoices.js +33 -0
  107. package/dist/components/form/engine/typeChoices.js.map +1 -0
  108. package/dist/components/form/expressions/DpqlRendering.d.ts +26 -0
  109. package/dist/components/form/expressions/DpqlRendering.d.ts.map +1 -0
  110. package/dist/components/form/expressions/DpqlRendering.js +53 -0
  111. package/dist/components/form/expressions/DpqlRendering.js.map +1 -0
  112. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  113. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  114. package/dist/components/form/expressions/ExpressionField.js +203 -45
  115. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  116. package/dist/components/form/expressions/argumentPresence.d.ts +84 -0
  117. package/dist/components/form/expressions/argumentPresence.d.ts.map +1 -0
  118. package/dist/components/form/expressions/argumentPresence.js +217 -0
  119. package/dist/components/form/expressions/argumentPresence.js.map +1 -0
  120. package/dist/components/form/expressions/builder/argumentLabel.d.ts.map +1 -1
  121. package/dist/components/form/expressions/builder/argumentLabel.js +9 -4
  122. package/dist/components/form/expressions/builder/argumentLabel.js.map +1 -1
  123. package/dist/components/form/expressions/builder/index.d.ts +15 -2
  124. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  125. package/dist/components/form/expressions/builder/index.js +21 -65
  126. package/dist/components/form/expressions/builder/index.js.map +1 -1
  127. package/dist/components/form/expressions/builder/renderTemplate.d.ts +4 -1
  128. package/dist/components/form/expressions/builder/renderTemplate.d.ts.map +1 -1
  129. package/dist/components/form/expressions/builder/renderTemplate.js +10 -67
  130. package/dist/components/form/expressions/builder/renderTemplate.js.map +1 -1
  131. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  132. package/dist/components/form/expressions/mockExpressions.js +8 -4
  133. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  134. package/dist/components/form/expressions/renderExpressionToText.d.ts +1 -1
  135. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  136. package/dist/components/form/expressions/renderExpressionToText.js +95 -19
  137. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  138. package/dist/components/form/expressions/types.d.ts +7 -0
  139. package/dist/components/form/expressions/types.d.ts.map +1 -1
  140. package/dist/components/form/expressions/useExpressions.d.ts.map +1 -1
  141. package/dist/components/form/expressions/useExpressions.js +5 -2
  142. package/dist/components/form/expressions/useExpressions.js.map +1 -1
  143. package/dist/components/form/expressions/useRenderExpression.d.ts +7 -16
  144. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  145. package/dist/components/form/expressions/useRenderExpression.js +50 -126
  146. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  147. package/dist/components/form/fields/Field.d.ts +18 -0
  148. package/dist/components/form/fields/Field.d.ts.map +1 -1
  149. package/dist/components/form/fields/Field.js +54 -8
  150. package/dist/components/form/fields/Field.js.map +1 -1
  151. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +11 -1
  152. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  153. package/dist/components/form/fields/allowed-values/AllowedValues.js +52 -8
  154. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  155. package/dist/components/form/fields/auto/AutoFormField.d.ts +7 -1
  156. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  157. package/dist/components/form/fields/auto/AutoFormField.js +91 -28
  158. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  159. package/dist/components/form/fields/date/Date.d.ts +1 -1
  160. package/dist/components/form/fields/date/Date.d.ts.map +1 -1
  161. package/dist/components/form/fields/date/Date.js +16 -2
  162. package/dist/components/form/fields/date/Date.js.map +1 -1
  163. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  164. package/dist/components/form/fields/long-string/LongString.js +14 -2
  165. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  166. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  167. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +50 -4
  168. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  169. package/dist/components/form/fields/rich-text/RichText.d.ts +47 -2
  170. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  171. package/dist/components/form/fields/rich-text/RichText.js +153 -12
  172. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  173. package/dist/components/form/fields/select/Select.d.ts.map +1 -1
  174. package/dist/components/form/fields/select/Select.js +120 -20
  175. package/dist/components/form/fields/select/Select.js.map +1 -1
  176. package/dist/components/form/fields/select/SelectCollection.d.ts +166 -9
  177. package/dist/components/form/fields/select/SelectCollection.d.ts.map +1 -1
  178. package/dist/components/form/fields/select/SelectCollection.js +200 -7
  179. package/dist/components/form/fields/select/SelectCollection.js.map +1 -1
  180. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +2 -1
  181. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  182. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -2
  183. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  184. package/dist/components/form/fields/template/TemplateField.d.ts +34 -2
  185. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  186. package/dist/components/form/fields/template/TemplateField.js +606 -58
  187. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  188. package/dist/components/form/fields/template/TemplateText.d.ts +36 -0
  189. package/dist/components/form/fields/template/TemplateText.d.ts.map +1 -0
  190. package/dist/components/form/fields/template/TemplateText.js +80 -0
  191. package/dist/components/form/fields/template/TemplateText.js.map +1 -0
  192. package/dist/components/form/index.d.ts +2 -0
  193. package/dist/components/form/index.d.ts.map +1 -1
  194. package/dist/components/form/index.js +4 -0
  195. package/dist/components/form/index.js.map +1 -1
  196. package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
  197. package/dist/components/smartEditor/SmartEditor.js +29 -8
  198. package/dist/components/smartEditor/SmartEditor.js.map +1 -1
  199. package/dist/components/smartEditor/useLspSession.d.ts +5 -1
  200. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  201. package/dist/components/smartEditor/useLspSession.js +16 -2
  202. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  203. package/dist/components/supportTicket/TicketReplyBox.d.ts +8 -4
  204. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -1
  205. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -1
  206. package/dist/helpers/common.d.ts.map +1 -1
  207. package/dist/helpers/common.js +7 -3
  208. package/dist/helpers/common.js.map +1 -1
  209. package/dist/helpers/dpqlDetection.d.ts +58 -0
  210. package/dist/helpers/dpqlDetection.d.ts.map +1 -0
  211. package/dist/helpers/dpqlDetection.js +164 -0
  212. package/dist/helpers/dpqlDetection.js.map +1 -0
  213. package/dist/helpers/optionUiTypes.d.ts +31 -3
  214. package/dist/helpers/optionUiTypes.d.ts.map +1 -1
  215. package/dist/helpers/optionUiTypes.js +36 -1
  216. package/dist/helpers/optionUiTypes.js.map +1 -1
  217. package/dist/helpers/options.d.ts +57 -0
  218. package/dist/helpers/options.d.ts.map +1 -1
  219. package/dist/helpers/options.js +97 -1
  220. package/dist/helpers/options.js.map +1 -1
  221. package/dist/helpers/templateItems.d.ts +47 -0
  222. package/dist/helpers/templateItems.d.ts.map +1 -0
  223. package/dist/helpers/templateItems.js +88 -0
  224. package/dist/helpers/templateItems.js.map +1 -0
  225. package/dist/helpers/templateText.d.ts +80 -0
  226. package/dist/helpers/templateText.d.ts.map +1 -0
  227. package/dist/helpers/templateText.js +291 -0
  228. package/dist/helpers/templateText.js.map +1 -0
  229. package/dist/helpers/templates.d.ts +30 -12
  230. package/dist/helpers/templates.d.ts.map +1 -1
  231. package/dist/helpers/templates.js +91 -33
  232. package/dist/helpers/templates.js.map +1 -1
  233. package/dist/helpers/validations.d.ts +17 -8
  234. package/dist/helpers/validations.d.ts.map +1 -1
  235. package/dist/helpers/validations.js +117 -22
  236. package/dist/helpers/validations.js.map +1 -1
  237. package/dist/hooks/useArgSchema.d.ts +2 -1
  238. package/dist/hooks/useArgSchema.d.ts.map +1 -1
  239. package/dist/hooks/useFetch/useFetch.d.ts +3 -1
  240. package/dist/hooks/useFetch/useFetch.d.ts.map +1 -1
  241. package/dist/hooks/useFetch/useFetch.js +47 -9
  242. package/dist/hooks/useFetch/useFetch.js.map +1 -1
  243. package/dist/hooks/useTemplates.d.ts +20 -0
  244. package/dist/hooks/useTemplates.d.ts.map +1 -1
  245. package/dist/hooks/useTemplates.js +34 -2
  246. package/dist/hooks/useTemplates.js.map +1 -1
  247. package/dist/index.d.ts +2 -1
  248. package/dist/index.d.ts.map +1 -1
  249. package/dist/index.js +5 -1
  250. package/dist/index.js.map +1 -1
  251. package/dist/utils/fetch.d.ts +151 -1
  252. package/dist/utils/fetch.d.ts.map +1 -1
  253. package/dist/utils/fetch.js +256 -44
  254. package/dist/utils/fetch.js.map +1 -1
  255. package/dist/utils/lspClient.d.ts +6 -0
  256. package/dist/utils/lspClient.d.ts.map +1 -1
  257. package/dist/utils/lspClient.js +62 -11
  258. package/dist/utils/lspClient.js.map +1 -1
  259. package/dist/utils/websocket.d.ts +6 -0
  260. package/dist/utils/websocket.d.ts.map +1 -1
  261. package/dist/utils/websocket.js +15 -7
  262. package/dist/utils/websocket.js.map +1 -1
  263. package/package.json +8 -4
  264. package/src/components/composer/Composer.tsx +7 -2
  265. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +3 -119
  266. package/src/components/dpqlEditor/DpqlEditor.tsx +5 -2
  267. package/src/components/dpqlEditor/dpqlHelpers.ts +20 -1
  268. package/src/components/dpqlEditor/dpqlProbe.ts +165 -0
  269. package/src/components/dpqlEditor/dpqlTags.ts +13 -0
  270. package/src/components/dpqlEditor/index.ts +4 -0
  271. package/src/components/dpqlEditor/types.ts +38 -2
  272. package/src/components/dpqlEditor/useDpqlProbe.ts +33 -0
  273. package/src/components/dpqlEditor/useDpqlSession.ts +20 -4
  274. package/src/components/form/engine/CompactRow.tsx +487 -160
  275. package/src/components/form/engine/CompactToolbar.tsx +10 -3
  276. package/src/components/form/engine/FormEngine.stories.tsx +1294 -161
  277. package/src/components/form/engine/FormEngine.tsx +916 -213
  278. package/src/components/form/engine/FormEngineStickyHeaders.stories.tsx +331 -0
  279. package/src/components/form/engine/FormFieldsSkeleton.tsx +77 -0
  280. package/src/components/form/engine/OptionFieldMessages.tsx +300 -14
  281. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +94 -3
  282. package/src/components/form/engine/compactRowContext.ts +15 -6
  283. package/src/components/form/engine/compactRowStyles.ts +56 -2
  284. package/src/components/form/engine/compactToolbarContext.ts +1 -1
  285. package/src/components/form/engine/optionActions.ts +89 -1
  286. package/src/components/form/engine/optionsContext.ts +24 -0
  287. package/src/components/form/engine/readFirst.ts +88 -8
  288. package/src/components/form/engine/rendererTypes.ts +48 -4
  289. package/src/components/form/engine/rowFocus.ts +101 -0
  290. package/src/components/form/engine/rowMenuContext.ts +159 -0
  291. package/src/components/form/engine/rowOpenPicker.ts +27 -0
  292. package/src/components/form/engine/typeChoices.ts +63 -0
  293. package/src/components/form/expressions/DpqlRendering.tsx +85 -0
  294. package/src/components/form/expressions/ExpressionField.stories.tsx +826 -48
  295. package/src/components/form/expressions/ExpressionField.tsx +269 -55
  296. package/src/components/form/expressions/argumentPresence.ts +213 -0
  297. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +38 -21
  298. package/src/components/form/expressions/builder/argumentLabel.tsx +10 -4
  299. package/src/components/form/expressions/builder/index.tsx +39 -81
  300. package/src/components/form/expressions/builder/renderTemplate.tsx +23 -56
  301. package/src/components/form/expressions/dpqlMockLanguage.ts +565 -0
  302. package/src/components/form/expressions/dpqlMockLsp.ts +109 -83
  303. package/src/components/form/expressions/mockExpressions.ts +8 -4
  304. package/src/components/form/expressions/renderExpressionToText.ts +112 -22
  305. package/src/components/form/expressions/types.ts +7 -0
  306. package/src/components/form/expressions/useExpressions.ts +5 -1
  307. package/src/components/form/expressions/useRenderExpression.ts +43 -127
  308. package/src/components/form/fields/Field.tsx +99 -20
  309. package/src/components/form/fields/allowed-values/AllowedValues.tsx +106 -44
  310. package/src/components/form/fields/auto/AutoFormField.stories.tsx +85 -18
  311. package/src/components/form/fields/auto/AutoFormField.tsx +97 -21
  312. package/src/components/form/fields/date/Date.tsx +17 -1
  313. package/src/components/form/fields/long-string/LongString.tsx +15 -3
  314. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +65 -6
  315. package/src/components/form/fields/rich-text/RichText.tsx +248 -30
  316. package/src/components/form/fields/select/Select.stories.tsx +392 -0
  317. package/src/components/form/fields/select/Select.tsx +164 -27
  318. package/src/components/form/fields/select/SelectCollection.tsx +350 -6
  319. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -2
  320. package/src/components/form/fields/template/TemplateField.stories.tsx +93 -14
  321. package/src/components/form/fields/template/TemplateField.tsx +825 -131
  322. package/src/components/form/fields/template/TemplateText.tsx +115 -0
  323. package/src/components/form/index.tsx +4 -0
  324. package/src/components/log/Log.stories.tsx +2 -1
  325. package/src/components/searchFilterBar/SearchFilterBar.stories.tsx +3 -3
  326. package/src/components/smartEditor/SmartEditor.tsx +41 -9
  327. package/src/components/smartEditor/__fixtures__/mockLspServer.ts +52 -3
  328. package/src/components/smartEditor/useLspSession.ts +21 -3
  329. package/src/components/supportTicket/TicketReplyBox.tsx +8 -4
  330. package/src/components/ticketThread/TicketThread.stories.tsx +1 -0
  331. package/src/helpers/common.ts +7 -3
  332. package/src/helpers/dpqlDetection.ts +209 -0
  333. package/src/helpers/optionUiTypes.ts +38 -0
  334. package/src/helpers/options.ts +96 -0
  335. package/src/helpers/templateItems.ts +81 -0
  336. package/src/helpers/templateText.ts +350 -0
  337. package/src/helpers/templates.ts +94 -41
  338. package/src/helpers/validations.ts +138 -39
  339. package/src/hooks/useFetch/useFetch.stories.tsx +6 -5
  340. package/src/hooks/useFetch/useFetch.tsx +53 -7
  341. package/src/hooks/useStorage/useStorage.stories.tsx +2 -1
  342. package/src/hooks/useTemplates.ts +41 -0
  343. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +2 -1
  344. package/src/index.tsx +14 -1
  345. package/src/stores/currentUser/currentUser.stories.tsx +2 -1
  346. package/src/stories/storyNetwork.ts +92 -0
  347. package/src/utils/fetch.ts +311 -21
  348. package/src/utils/lspClient.ts +68 -11
  349. package/src/utils/websocket.ts +20 -9
  350. package/dist/components/form/engine/__fixtures__/basicFormOptions.d.ts +0 -73
  351. package/dist/components/form/engine/__fixtures__/basicFormOptions.d.ts.map +0 -1
  352. package/dist/components/form/engine/__fixtures__/basicFormOptions.js +0 -317
  353. package/dist/components/form/engine/__fixtures__/basicFormOptions.js.map +0 -1
  354. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.d.ts +0 -41
  355. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.d.ts.map +0 -1
  356. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.js +0 -45
  357. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.js.map +0 -1
  358. package/dist/components/form/expressions/dpqlMockLsp.d.ts +0 -3
  359. package/dist/components/form/expressions/dpqlMockLsp.d.ts.map +0 -1
  360. package/dist/components/form/expressions/dpqlMockLsp.js +0 -91
  361. package/dist/components/form/expressions/dpqlMockLsp.js.map +0 -1
  362. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +0 -2
  363. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +0 -1
  364. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +0 -9
  365. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +0 -1
  366. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts +0 -55
  367. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts.map +0 -1
  368. package/dist/components/smartEditor/__fixtures__/mockLspServer.js +0 -126
  369. package/dist/components/smartEditor/__fixtures__/mockLspServer.js.map +0 -1
  370. package/dist/stories/Data/fileFieldArgSchema.json +0 -86
@@ -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,27 +38,31 @@ import {
38
38
  TQorusFormOperatorValue,
39
39
  TQorusType,
40
40
  } from '@qoretechnologies/ts-toolkit';
41
- import { resolveOptionActions, TOptionActions } from './optionActions';
41
+ import { shouldMarkAsExpression } from '../expressions/argumentPresence';
42
+ import { offersTypeChoices } from './typeChoices';
43
+ import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions';
42
44
  import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
43
- import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last } from 'lodash';
44
- import isArray from 'lodash/isArray';
45
+ import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last, uniq } from 'lodash';
45
46
  import map from 'lodash/map';
46
47
  import reduce from 'lodash/reduce';
47
48
  import size from 'lodash/size';
48
49
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
49
50
  import { useMeasure, useMount, useUpdateEffect } from 'react-use';
50
51
  import styled, { css } from 'styled-components';
51
- import { createContext } from 'use-context-selector';
52
52
  import {
53
53
  fixOperatorValue,
54
54
  getDefaultValue,
55
55
  insertAtIndex,
56
56
  richtextToString,
57
57
  } from '../../../helpers/common';
58
- import { getRequiredOptionMessage } from '../../../helpers/options';
58
+ import {
59
+ getRequiredOptionMessage,
60
+ resolveDegenerateRequiredGroups,
61
+ } from '../../../helpers/options';
59
62
  import {
60
63
  IValidationResult,
61
64
  hasAllDependenciesFullfilled,
65
+ parseDependency,
62
66
  validateField,
63
67
  validateFieldWithResult,
64
68
  } from '../../../helpers/validations';
@@ -75,6 +79,7 @@ import {
75
79
  isValueTemplate,
76
80
  } from '../fields/template/TemplateField';
77
81
  import { CompactRow } from './CompactRow';
82
+ import { FormFieldsSkeleton } from './FormFieldsSkeleton';
78
83
  import { CompactRowContext, ICompactRowContext, TCodePreviewRenderer } from './compactRowContext';
79
84
  import {
80
85
  GROUP_INDENT,
@@ -99,13 +104,16 @@ import {
99
104
  import {
100
105
  IConditionalFieldMessage,
101
106
  OptionFieldMessages,
107
+ getAllowedValueAvailability,
102
108
  getShownSchemaMessages,
103
109
  } from './OptionFieldMessages';
110
+ import { OptionsContext } from './optionsContext';
104
111
  import {
105
112
  MarkdownRendererContext,
106
113
  TMarkdownRenderer,
107
114
  } from '../../Description/markdownRendererContext';
108
115
  import { OptionsHelpDialog } from './OptionsHelpDialog';
116
+ import { firstDeclaredType, isUntypedOptionType } from '../../../helpers/optionUiTypes';
109
117
  import {
110
118
  TReadFirstStatus,
111
119
  findAllowedValueOption,
@@ -151,8 +159,25 @@ export type TOperatorValue = TQorusFormOperatorValue;
151
159
  */
152
160
  export interface IOptionFieldMessage
153
161
  extends IQorusFormFieldMessage, Pick<IConditionalFieldMessage, 'when' | 'unless'> {}
154
- export type IOptionsSchemaArg = TQorusFormFieldSchema;
155
- export interface IOptionsSchema extends IQorusFormSchema {}
162
+ /** `T` without `K`, member by member — `Omit` on a union collapses the union. */
163
+ type TDistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
164
+ /**
165
+ * One field of a form's schema: the shared descriptor, plus what this engine
166
+ * reads that the shared type does not carry — conditional `messages`, and
167
+ * `absorb_fields`. Declared, so a descriptor using them is checked rather than
168
+ * cast; still assignable wherever the shared descriptor is expected.
169
+ */
170
+ export type IOptionsSchemaArg = TDistributiveOmit<TQorusFormFieldSchema, 'messages'> & {
171
+ messages?: IOptionFieldMessage[];
172
+ /**
173
+ * Siblings this field renders inside its own editor instead of on rows of
174
+ * their own — a code editor and its language, say.
175
+ */
176
+ absorb_fields?: string[];
177
+ };
178
+ export interface IOptionsSchema {
179
+ [optionName: string]: IOptionsSchemaArg;
180
+ }
156
181
  export interface IOperator extends IQorusFormOperator {}
157
182
  export interface IOperatorsSchema extends IQorusFormOperatorsSchema {}
158
183
  export interface IOptionsOnChangeMeta extends IQorusFormFieldOnChangeMeta {
@@ -214,6 +239,48 @@ const resolveInheritProps = (
214
239
  return out;
215
240
  };
216
241
 
242
+ /** One shared "nothing came down from above" bag. See `inheritedScopeFor`. */
243
+ const NO_INHERITED_SCOPE: Record<string, unknown> = Object.freeze({});
244
+
245
+ /**
246
+ * The inheritance bag ONE field forwards to any `arg_schema` sub-form it hosts:
247
+ * whatever the ancestor chain passed down, plus whatever this field's own
248
+ * `inherit_props` resolves to.
249
+ *
250
+ * Written as a function with a stable answer because it is a PROP, and a prop
251
+ * that is a new object on every render re-renders the field that receives it
252
+ * every time this form renders — for most fields, forever, over a value that
253
+ * never changed. It showed up in the console as `Template field kind Updated
254
+ * because: {inheritedFromParent}` repeating on a form nobody was touching:
255
+ * `kind` declares no `inherit_props` at all, so the bag it was handed was
256
+ * `{ ...(undefined ?? {}) }` — a fresh empty object each time, unequal to the
257
+ * last one, and `TemplateField` is memoised precisely so it does not re-render
258
+ * on a prop that did not change.
259
+ *
260
+ * So a field with nothing of its own to add forwards the bag it was HANDED,
261
+ * unchanged and unwrapped, and a form with no inherited scope at all forwards
262
+ * one shared frozen object rather than minting one per field per render.
263
+ *
264
+ * Mirrored in qorus-ide's `systemOptions.tsx` (which grew this treatment
265
+ * first); per the CLAUDE.md rule there, the forwarding logic is kept identical
266
+ * in both until FormEngine replaces that renderer.
267
+ *
268
+ * @param inheritProps the field's `inherit_props` map, if it declares one
269
+ * @param availableOptions this form's fields, where a sibling's value is read
270
+ * @param inheritedFromParent the bag threaded in from an outer form
271
+ */
272
+ export const inheritedScopeFor = (
273
+ inheritProps: Record<string, string> | undefined,
274
+ availableOptions: TQorusForm | undefined,
275
+ inheritedFromParent: Record<string, unknown> | undefined
276
+ ): Record<string, unknown> => {
277
+ const resolved = resolveInheritProps(inheritProps, availableOptions, inheritedFromParent);
278
+ if (!Object.keys(resolved).length) {
279
+ return inheritedFromParent ?? NO_INHERITED_SCOPE;
280
+ }
281
+ return { ...(inheritedFromParent ?? {}), ...resolved };
282
+ };
283
+
217
284
  const NegativeColorEffect: any = {
218
285
  gradient: {
219
286
  colors: { 0: 'danger', 100: 'danger:darken:2' },
@@ -257,9 +324,9 @@ const StyledCompactWrap = styled.div<{ $ownScroll?: boolean }>`
257
324
  toolbar silently stops pinning. \`clip\` keeps the horizontal guard without
258
325
  that coercion. */
259
326
  ${({ $ownScroll }) =>
260
- $ownScroll
261
- ? css`
262
- /* 'own': we are the scroller. The sticky toolbar pins to whatever
327
+ $ownScroll ?
328
+ css`
329
+ /* 'own': we are the scroller. The sticky toolbar pins to whatever
263
330
  scrolls, so a host whose scrollport carries top padding would
264
331
  resolve sticky \`top: 0\` against its padding box and leave an
265
332
  unblurred strip above the toolbar. Scrolling in this unpadded box
@@ -268,22 +335,22 @@ const StyledCompactWrap = styled.div<{ $ownScroll?: boolean }>`
268
335
 
269
336
  \`min-height: 0\` is what lets a flex item shrink below its content
270
337
  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
338
+ min-height: 0;
339
+ max-height: 100%;
340
+ overflow-y: auto;
341
+ overflow-x: hidden;
342
+ `
343
+ : css`
344
+ /* \`min-height: auto\` is load-bearing, not a default: inside a column
278
345
  flex parent the shrink allowance above collapses this box to zero
279
346
  height while its rows paint outside it, so the form looks right but
280
347
  contributes nothing to layout — the host cannot size or scroll to
281
348
  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
- `}
349
+ min-height: auto;
350
+ max-height: none;
351
+ overflow-y: visible;
352
+ overflow-x: clip;
353
+ `}
287
354
 
288
355
  /* Option logos (e.g. language images) render as <img> inside ReqoreIcon's
289
356
  square box; constrain them so portrait PNGs don't overflow the row. */
@@ -318,7 +385,7 @@ export const getType = (
318
385
  operator?: TOperatorValue
319
386
  ): TQorusType => {
320
387
  const finalType = getTypeFromOperator(operators, fixOperatorValue(operator)) || type;
321
- const resolvedType = isArray(finalType) ? finalType[0] : finalType;
388
+ const resolvedType = firstDeclaredType(finalType);
322
389
 
323
390
  // A value can be received before its server-driven option schema. Keep the
324
391
  // renderer type-safe during that transition without guessing a concrete type.
@@ -355,17 +422,59 @@ const getOptionSchemaStorageType = (
355
422
  : isRendererOnly(option?.ui_type) ? option?.type || option?.ui_type
356
423
  : option?.ui_type || option?.type) || 'any') as TQorusType;
357
424
 
358
- const getOptionFieldStorageType = (
425
+ /**
426
+ * Whether an option currently holds an expression, in EITHER shape it comes in.
427
+ *
428
+ * At runtime the editor writes the flag onto the option itself
429
+ * (`{ type, is_expression: true, value }`), but a value loaded from a saved
430
+ * draft carries the envelope NESTED (`{ type, value: { is_expression: true,
431
+ * value } }`). Same thing, two shapes, and only the flat one was ever checked —
432
+ * so a freshly typed expression behaved while a reloaded one did not, which is
433
+ * exactly how this survived several fixes: every test used the runtime shape.
434
+ */
435
+ const optionHoldsExpression = (option: unknown): boolean => {
436
+ const o = option as { is_expression?: unknown; value?: { is_expression?: unknown } } | undefined;
437
+ return !!(o?.is_expression || o?.value?.is_expression);
438
+ };
439
+
440
+ /**
441
+ * Which type an option resolves to — the schema's, or the one stored on the
442
+ * value.
443
+ *
444
+ * Exported because this single decision is what every option field is rendered
445
+ * with, and what an expression's return type is checked against. It deserves
446
+ * to be assertable without standing up a form.
447
+ */
448
+ export const getOptionFieldStorageType = (
359
449
  optionName: string,
360
450
  fieldType: TQorusType | TQorusType[] | undefined,
361
451
  schema?: IQorusFormSchema,
362
452
  operators?: IOperatorsSchema,
363
453
  operatorData?: TOperatorValue,
364
- isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
454
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType,
455
+ /** Whether the stored value is an expression — see below. */
456
+ isExpression?: boolean
365
457
  ): TQorusType => {
366
458
  const schemaOption = schema?.[optionName];
459
+ /* A STORED type does not describe an expression.
460
+
461
+ An expression value is `{ is_expression: true, value: {...} }`, so the type
462
+ recorded beside it describes that envelope — `hash` — and not what the
463
+ field holds. Letting it win means the SCHEMA's declared type is ignored and
464
+ the wrong answer is handed to the expression editor as the return type to
465
+ check against: `1 + 2` on a field declared `"type": "auto"` came back
466
+ "The expression returns int, and this field holds hash". A literal `1` was
467
+ fine, because a literal is stored as a plain value whose type matches.
468
+
469
+ `auto`/`any` accept anything, which is exactly why nothing should be
470
+ checked against them; a concrete declared type still is. The schema is the
471
+ only party that knows what the field accepts. */
367
472
  const storedType =
368
- fieldType && !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType)) ?
473
+ (
474
+ !isExpression &&
475
+ fieldType &&
476
+ !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType))
477
+ ) ?
369
478
  fieldType
370
479
  : getOptionSchemaStorageType(schemaOption, isRendererOnly);
371
480
 
@@ -376,10 +485,10 @@ export const hasRequiredOptions = (options: IQorusFormSchema = {}) => {
376
485
  return !!findKey(options, (option) => option.required);
377
486
  };
378
487
 
379
- export const OptionsContext = createContext<{
380
- schema?: IQorusFormSchema;
381
- value?: TQorusForm;
382
- }>({});
488
+ /* Re-exported from the module that owns it so the existing import path keeps
489
+ working; see `optionsContext.ts` for why it is not declared here. */
490
+ export { OptionsContext } from './optionsContext';
491
+ export type { IOptionsContext } from './optionsContext';
383
492
 
384
493
  export const fixOptions = (
385
494
  value: TQorusForm | TQorusFlatForm = {},
@@ -557,6 +666,85 @@ const normalizeEmptyFieldValues = (fields: TQorusForm | TQorusFlatForm | undefin
557
666
  {} as TQorusForm
558
667
  );
559
668
 
669
+ /**
670
+ * Does this rebuild say anything the parent does not already hold?
671
+ *
672
+ * `false` means the rebuild is a MIRROR: `fixOptions` found no default to
673
+ * materialise, so the result repeats the `value` the parent handed down. The
674
+ * compare is empty-normalized because `fixOptions` round-trips a required-empty
675
+ * field between `{ value: '' }` and no `value` key, and that cosmetic
676
+ * difference would otherwise read as new information forever.
677
+ */
678
+ export const isMirrorOfValue = (
679
+ fixedValue: TQorusForm | TQorusFlatForm | undefined,
680
+ value: TQorusForm | TQorusFlatForm | undefined
681
+ ): boolean => isEqual(normalizeEmptyFieldValues(fixedValue), normalizeEmptyFieldValues(value));
682
+
683
+ /**
684
+ * What this rebuild lets the form agree with the parent on, if anything.
685
+ *
686
+ * Returns the rebuild when it mirrors `value`, and `undefined` when it
687
+ * contributed something the parent has yet to learn.
688
+ *
689
+ * Adopting the parent's value IS agreement, and saying so matters as much as
690
+ * staying quiet about it. The sync-down skips only when the parent echoed what
691
+ * this form last sent; if adopting a value never updates that record, it points
692
+ * at an older state forever, the skip can never fire again, and the form
693
+ * rebuilds on every single change. Holding the mirror back WITHOUT recording
694
+ * the agreement was measured making the starvation worse, not better — 8 s
695
+ * became a 45 s timeout — which is why the two belong together.
696
+ */
697
+ export const agreementFromRebuild = (
698
+ fixedValue: TQorusForm | TQorusFlatForm | undefined,
699
+ value: TQorusForm | TQorusFlatForm | undefined
700
+ ): TQorusForm | TQorusFlatForm | undefined =>
701
+ isMirrorOfValue(fixedValue, value) ? fixedValue : undefined;
702
+
703
+ /** The state the emit decision is made against. */
704
+ export interface IEmitDecision {
705
+ /** The form's own copy of the value, as it stands now. */
706
+ localValue: TQorusForm | TQorusFlatForm | undefined;
707
+ /** The `value` prop, read at the moment the decision is made. */
708
+ value: TQorusForm | TQorusFlatForm | undefined;
709
+ /**
710
+ * The last rebuild that mirrored `value` without adding anything, if the most
711
+ * recent one did. `undefined` when the last rebuild contributed something.
712
+ */
713
+ mirroredValue: TQorusForm | TQorusFlatForm | undefined;
714
+ }
715
+
716
+ /**
717
+ * Should the form report `localValue` to its parent as an answer?
718
+ *
719
+ * Extracted and pure because the situation it exists for cannot be reproduced
720
+ * through a rendered form: it needs the emit to be decided against a `value`
721
+ * NEWER than the `localValue` built from it, which only happens when two
722
+ * components' effects land in one React commit — and a test renderer flushes
723
+ * effects between updates, so the two never fall out of step. The states below
724
+ * were recorded from a live instrumented run instead, and this is what makes
725
+ * them assertable.
726
+ *
727
+ * Two ways the answer is no:
728
+ *
729
+ * 1. `localValue` is a mirror — a rebuild of the parent's own value that added
730
+ * nothing. Repeating it back is not an answer. **Checked first**, because
731
+ * the comparison in (2) reads whatever `value` has become by now, which,
732
+ * when a nested engine shares the parent value, is already that engine's
733
+ * newer write; comparing against it would find a difference this form never
734
+ * made and emit the older mirror over the newer write. That is the loop.
735
+ * 2. `localValue` already equals `value` — there is nothing to report.
736
+ */
737
+ export const shouldEmitLocalValue = ({
738
+ localValue,
739
+ value,
740
+ mirroredValue,
741
+ }: IEmitDecision): boolean => {
742
+ if (mirroredValue !== undefined && isEqual(localValue, mirroredValue)) {
743
+ return false;
744
+ }
745
+ return !isEqual(localValue, value);
746
+ };
747
+
560
748
  export const flattenOptions = (options: TQorusForm): TQorusFlatForm => {
561
749
  return reduce(
562
750
  options,
@@ -592,7 +780,7 @@ export const getTypeAndCanBeNull = (
592
780
  return {
593
781
  type: realType,
594
782
  defaultType: realType,
595
- defaultInternalType: realType === 'auto' || realType === 'any' ? undefined : realType,
783
+ defaultInternalType: isUntypedOptionType(realType) ? undefined : realType,
596
784
  canBeNull,
597
785
  };
598
786
  };
@@ -610,7 +798,7 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
610
798
  name: string;
611
799
  uniqueName?: string;
612
800
  value?: TQorusForm | TQorusFlatForm;
613
- options?: IQorusFormSchema;
801
+ options?: IOptionsSchema;
614
802
  onChange?: (name: string, value?: TQorusForm, meta?: IOptionsOnChangeMeta) => void;
615
803
  /**
616
804
  * `'immediate'` (default): edits flow out via the debounced `onChange`.
@@ -652,7 +840,28 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
652
840
  onOptionsLoaded?: (options: IQorusFormSchema) => void;
653
841
  recordRequiresSearchOptions?: boolean;
654
842
  readOnly?: boolean;
843
+ /**
844
+ * What a click on a READ-ONLY row does, when there is somewhere to send it.
845
+ *
846
+ * A read-only row opens nothing by default — it shows everything it has and
847
+ * answers a click with silence, which is right when the form is the only
848
+ * place the value exists. Where the same field IS editable somewhere else,
849
+ * that silence is a dead end: the reader clicking a value they want to
850
+ * change has said exactly what they want.
851
+ *
852
+ * Given this, each row becomes the way in to that place and announces it —
853
+ * it takes a button's role, keyboard handling and cursor. Omit it and
854
+ * nothing changes.
855
+ */
856
+ onReadOnlyActivate?: (optionName: string) => void;
655
857
  allowTemplates?: boolean;
858
+ /**
859
+ * The form's SHARED template vocabulary — config items, system properties —
860
+ * offered to every field that supports templates.
861
+ *
862
+ * A field may also declare `templates` of its own in the schema, and that
863
+ * narrower list wins for that field: see {@link TFieldWithOwnTemplates}.
864
+ */
656
865
  stringTemplates?: IReqoreFormTemplates;
657
866
  /** Opt-in: fetch global templates from `system/getContextData` for this context. */
658
867
  interfaceContext?: string;
@@ -727,13 +936,78 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
727
936
  * the reader is choosing WHICH to open rather than reading this one, e.g. a
728
937
  * template's per-action option forms stacked in a drawer.
729
938
  *
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.
939
+ * One escape is not negotiable: a box always opens while a search query is
940
+ * running (`ReqorePanel` unmounts collapsed content, so a match inside a
941
+ * closed box would be unreachable).
942
+ *
943
+ * A second applies only by DEFAULT: the Optional box stays open when every
944
+ * row is optional, because a form that collapsed its only box would render a
945
+ * card that looks empty and broken. Passing this prop explicitly waives it —
946
+ * a host that supplies its own heading and explanation around the form (a
947
+ * run-options panel, say) has already told the reader what the card is, and
948
+ * asked for the settings to start folded away.
735
949
  */
736
950
  compactCollapsedGroups?: TFormEngineBoxKey[];
951
+ /**
952
+ * Compact mode only: rename the status boxes.
953
+ *
954
+ * The three are `Needs attention`, `Set` and `Optional`, which read correctly
955
+ * when the form is the page's subject. Inside a host that already named the
956
+ * subject — a panel headed "Change a setting for this run" — a bare
957
+ * `Optional` names a category rather than the things in it, and
958
+ * `Optional settings` is what the reader is looking at. Only the labels
959
+ * change; bucketing, icons and intents are untouched.
960
+ */
961
+ compactBoxLabels?: Partial<Record<TFormEngineBoxKey, string>>;
962
+ /**
963
+ * Compact mode only: the three status-box headers stay pinned to the top of
964
+ * whatever scrolls while their own box is in view. Default `true`.
965
+ *
966
+ * A box header is the only thing that says which bucket a row is in — `Set`,
967
+ * `Needs attention`, `Optional`. On a form long enough to scroll it leaves
968
+ * the screen, and every row below it then reads as an undifferentiated list:
969
+ * the reader deep inside `Optional` has no way to tell it from `Set` except
970
+ * by scrolling back. Pinning the header is what keeps the answer on screen.
971
+ *
972
+ * ALL THREE behave identically, deliberately. They are one control rendered
973
+ * three times, differing only in label, tint and icon; a reader who has
974
+ * scrolled into the Optional box needs to know that exactly as much as one
975
+ * who has scrolled into Set, and a rule that pinned only some of them would
976
+ * make the absence of a pinned header mean two different things (this box has
977
+ * no header vs. you are above every box).
978
+ *
979
+ * A header can never outlive its own box: `position: sticky` is bounded by
980
+ * its containing block, so each header unpins as its box scrolls away and at
981
+ * most ONE box header is ever pinned at a time. The pin costs one header's
982
+ * height, not three.
983
+ *
984
+ * Off by construction for a nested (`arg_schema`) sub-form — it sits inside
985
+ * the parent form's card and owns no scroll context to pin against.
986
+ *
987
+ * On a NARROW form this also takes the pin off the form's own toolbar, so the
988
+ * two never cost a third of a phone's screen between them — see the note at
989
+ * the panel's `stickyHeader`.
990
+ *
991
+ * Turn it off where the form is one item in a scrolling LIST of forms: there
992
+ * the reader is choosing which form to read, and a box header pinned from the
993
+ * item they have scrolled past is chrome belonging to something they have
994
+ * left.
995
+ */
996
+ compactStickyBoxHeaders?: boolean;
997
+ /**
998
+ * Compact mode only: how many pixels of the HOST's own sticky chrome sit
999
+ * above this form, inside the same scroll container.
1000
+ *
1001
+ * Sticky offsets do not compose by themselves — every pinned thing resolves
1002
+ * `top` against the same scrollport, so two of them with the same offset land
1003
+ * on top of each other. A host that pins a bar of its own (a page header
1004
+ * carrying a name and a Run control, say) measures it and passes the height
1005
+ * here; the form then pins its toolbar below that bar, and its box headers
1006
+ * below the toolbar, instead of underneath both.
1007
+ *
1008
+ * Default `0` — the form is the topmost pinned thing in its scroller.
1009
+ */
1010
+ compactStickyOffset?: number;
737
1011
  /**
738
1012
  * Compact mode only: which parts of the form's toolbar to show. `true`
739
1013
  * (default) shows all of it; `false` hides the whole thing.
@@ -781,6 +1055,37 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
781
1055
  * a consumer injecting many actions can never blow out the row.
782
1056
  */
783
1057
  optionActionsCollapse?: 'auto' | 'always' | 'never';
1058
+ /**
1059
+ * The HOST is still loading something this form needs.
1060
+ *
1061
+ * Declared because it was already read (`rest.skeleton`) and honoured by the
1062
+ * loading gate, but undeclared props are invisible to the people who need
1063
+ * them — and the reason to reach for this is not obvious.
1064
+ *
1065
+ * A host that resolves anything before it can render a form — an option list
1066
+ * fetched by `get_message`, a schema id resolved over the wire — has a wait of
1067
+ * its own that ends just as this component's begins. Drawing its own
1068
+ * placeholder for that first half and then handing over produces TWO
1069
+ * placeholders in series for one load, from two different components: the DOM
1070
+ * node is replaced, the geometry can differ, and the page relayouts in the
1071
+ * middle of waiting. Measured on the Qorus alert rule, whose three sections
1072
+ * each did this — six placeholder mounts where three would do.
1073
+ *
1074
+ * Passing the host's own wait in here instead makes ONE component own the
1075
+ * whole wait, from the host's first fetch to this form's last, with one
1076
+ * placeholder and one DOM node.
1077
+ */
1078
+ skeleton?: boolean;
1079
+ /**
1080
+ * What the HOST is waiting for, when it hands its wait down via `skeleton`.
1081
+ *
1082
+ * Without it every host wait reads as the same word and the reading stops at
1083
+ * the package boundary — measured on the live alert rule, three sections all
1084
+ * reported `host` and the next step was guessing which of the host's own five
1085
+ * conditions had fired. Surfaced as `data-wait` on the placeholder.
1086
+ */
1087
+ skeletonReason?: string;
1088
+
784
1089
  /**
785
1090
  * SEAM (reqraft): consumer-injected field editors for types reqraft doesn't
786
1091
  * ship (IDE domain fields). Keyed by field `type`/`ui_type`; forwarded through
@@ -817,6 +1122,28 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
817
1122
  * consumer's new editor no longer needs a reqraft release to be handled.
818
1123
  */
819
1124
  rendererOnlyUiTypes?: string[];
1125
+ /**
1126
+ * The `ui_type` names whose own editor renders templates as chips INLINE.
1127
+ *
1128
+ * For these the template selector is suppressed and the field's editor is
1129
+ * shown instead — the selector would replace a control the author can type in
1130
+ * with one they can only pick from. Merged with reqraft's built-in list
1131
+ * (`richtext`), the same way `rendererOnlyUiTypes` is.
1132
+ */
1133
+ templateAwareUiTypes?: string[];
1134
+ /**
1135
+ * Extra `ui_type` names that mean "a value of any type", for the purpose of
1136
+ * offering the per-type choices in the row's menu.
1137
+ *
1138
+ * A field that pins no concrete type offers "enter a value of type X" for
1139
+ * each type the form knows. That was gated on the schema saying
1140
+ * `ui_type: 'any'` alone, which misses two cases: an option that declares its
1141
+ * untypedness as `type: 'auto'` and carries no `ui_type` at all (the server
1142
+ * spells an assertion's Expected Value exactly that way), and an option whose
1143
+ * `ui_type` is a HOST's own untyped editor. Merged with reqraft's built-ins
1144
+ * (`any`, `auto`), the same way `templateAwareUiTypes` is.
1145
+ */
1146
+ anyLikeUiTypes?: string[];
820
1147
 
821
1148
  /**
822
1149
  * Bag of values forwarded from an outer FormEngine scope, used as a
@@ -878,12 +1205,17 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
878
1205
  * for the pane to exist at all — so a pasted or reloaded link lands on a
879
1206
  * collapsed form.
880
1207
  *
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.
1208
+ * Applied once, on the first render that actually CONTAINS one of the named
1209
+ * rows — not merely the first render with any rows — so a schema arriving in
1210
+ * pieces still gets its address applied rather than spending the one shot on
1211
+ * a form the field had not reached yet.
1212
+ *
1213
+ * It never re-expands a row the user has since collapsed, and it does not
1214
+ * participate in `expandMode: 'single'` accordion collapsing — the caller is
1215
+ * naming a starting point, not driving the state. That holds against the
1216
+ * engine's own openers too: `expandFirstRequired` / `autoFocusFirstRequired`
1217
+ * open their target ALONGSIDE these rows in single mode instead of replacing
1218
+ * them. Only a remount re-arms it. No-op in classic (non-compact) mode.
887
1219
  */
888
1220
  initialExpandedOptions?: string[];
889
1221
 
@@ -925,6 +1257,7 @@ const FormEngineImpl = ({
925
1257
  onOptionsLoaded,
926
1258
  recordRequiresSearchOptions,
927
1259
  readOnly,
1260
+ onReadOnlyActivate,
928
1261
  allowTemplates = true,
929
1262
  interfaceContext,
930
1263
  templateFieldProps,
@@ -941,7 +1274,10 @@ const FormEngineImpl = ({
941
1274
  compactNested = false,
942
1275
  compactScroll = 'host',
943
1276
  compactPanelProps,
944
- compactCollapsedGroups = ['optional'],
1277
+ compactCollapsedGroups: compactCollapsedGroupsProp,
1278
+ compactBoxLabels,
1279
+ compactStickyBoxHeaders = true,
1280
+ compactStickyOffset = 0,
945
1281
  compactToolbar = true,
946
1282
  commitMode = 'immediate',
947
1283
  expandMode = 'single',
@@ -959,6 +1295,8 @@ const FormEngineImpl = ({
959
1295
  // onto a DOM node
960
1296
  markdownRenderer: _markdownRenderer, // eslint-disable-line @typescript-eslint/no-unused-vars
961
1297
  rendererOnlyUiTypes,
1298
+ templateAwareUiTypes,
1299
+ anyLikeUiTypes,
962
1300
  inheritedFromParent,
963
1301
  autoFocusFirstRequired,
964
1302
  expandFirstRequired,
@@ -977,17 +1315,27 @@ const FormEngineImpl = ({
977
1315
  // from Reqore's context (one subscription for the whole app) rather than a
978
1316
  // reqraft-local matchMedia hook.
979
1317
  //
980
- // `isHoverCapable` defaults to `true` in Reqore, but fall back explicitly so
981
- // an older Reqore — where the property does not exist — degrades to "this
982
- // pointer hovers" (the pre-existing behaviour) instead of collapsing every
983
- // action into a menu for everyone.
984
- const isHoverCapable = useReqoreProperty('isHoverCapable') ?? true;
1318
+ // Both are always set by `ReqoreProvider`, and the peer floor (>= 0.74.0) is
1319
+ // the version that sets them. A defensive `?? true` here would be dead code:
1320
+ // `useReqoreProperty` THROWS on a context that lacks the key, so an older
1321
+ // Reqore fails at the hook rather than degrading to a default.
1322
+ const isHoverCapable = useReqoreProperty('isHoverCapable');
985
1323
  const isMobile = useReqoreProperty('isMobile');
986
1324
  const collapseOptionActions =
987
1325
  optionActionsCollapse === 'always' ? true
988
1326
  : optionActionsCollapse === 'never' ? false
989
1327
  : !isHoverCapable || !!isMobile;
990
- const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
1328
+ /* The schema, as the form should ASK it — not always as it was served.
1329
+ `resolveDegenerateRequiredGroups` turns a one-of group that has a single
1330
+ member in this schema back into a plain required field, because a choice
1331
+ with one option is not a choice. Applied here, at the one place the schema
1332
+ enters the component, so the header, the chips, the messages, the meter and
1333
+ the read-first summary cannot disagree about it: every one of them reads
1334
+ `options`, and none of them has to know the rule. */
1335
+ const [servedOptions, setOptions] = useState<IQorusFormSchema | undefined>(
1336
+ rest?.options || undefined
1337
+ );
1338
+ const options = useMemo(() => resolveDegenerateRequiredGroups(servedOptions), [servedOptions]);
991
1339
  // optionsLoader lifecycle: loading feeds the skeleton gate, error the banner.
992
1340
  const [optionsLoading, setOptionsLoading] = useState<boolean>(!!optionsLoader && !rest?.options);
993
1341
  const [optionsError, setOptionsError] = useState<string | undefined>();
@@ -1079,6 +1427,67 @@ const FormEngineImpl = ({
1079
1427
  },
1080
1428
  [compactWrapRef]
1081
1429
  );
1430
+ /* Declared here, not where it is first USED in the render: the sticky
1431
+ measurement below takes it as a dependency, and a dependency array is
1432
+ evaluated during render — a `const` declared further down would still be
1433
+ in its temporal dead zone. */
1434
+ const compactNarrow = !!compactWrapWidth && compactWrapWidth < 480;
1435
+
1436
+ /* How much pinned chrome sits above a status-box header.
1437
+
1438
+ Sticky offsets do not compose on their own: everything pinned in one
1439
+ scroller resolves `top` against the same scrollport, so a box header and
1440
+ this form's own toolbar both asking for 0 land on top of each other — and
1441
+ the toolbar is the loser, because it renders first and the box header wins
1442
+ on z-order. Covering it hides the search, which is the one control that
1443
+ forces a collapsed box open.
1444
+
1445
+ So the box headers pin BELOW the toolbar, at the host's own offset plus the
1446
+ toolbar's height. Measured, not assumed: the toolbar is a completion meter,
1447
+ a search row and a field picker that each hide themselves when they have
1448
+ nothing to say, and what is left wraps differently at every form width.
1449
+ Zero when this form draws no toolbar at all. */
1450
+ const [compactToolbarHeight, setCompactToolbarHeight] = useState(0);
1451
+ useLayoutEffect(() => {
1452
+ const wrap = compactWrapNode;
1453
+ if (!compact || !wrap || compactNested || !compactStickyBoxHeaders) {
1454
+ setCompactToolbarHeight(0);
1455
+ return undefined;
1456
+ }
1457
+ const header = wrap.querySelector<HTMLElement>(
1458
+ ':scope > .reqore-panel > .reqore-panel-title'
1459
+ );
1460
+ if (!header) {
1461
+ setCompactToolbarHeight(0);
1462
+ return undefined;
1463
+ }
1464
+ const read = () =>
1465
+ setCompactToolbarHeight((prev) => {
1466
+ /* Only a toolbar that is ITSELF pinned takes room off the top. A host
1467
+ can switch the pin off (`compactPanelProps.stickyHeader`), and it
1468
+ does where the form is a couple of fields inside a longer page — and
1469
+ a box header offset by a toolbar that scrolls away would float a
1470
+ header's height below the top edge with nothing under it. Read from
1471
+ the element rather than from the props that produced it, so every
1472
+ route to that decision is accounted for. */
1473
+ const pinned = getComputedStyle(header).position === 'sticky';
1474
+ const next = pinned ? Math.round(header.getBoundingClientRect().height) : 0;
1475
+ return prev === next ? prev : next;
1476
+ });
1477
+ read();
1478
+ /* jsdom and older engines have no ResizeObserver; the one-shot read above
1479
+ still gives the right answer for a form whose toolbar never reflows. */
1480
+ if (typeof ResizeObserver === 'undefined') {
1481
+ return undefined;
1482
+ }
1483
+ const observer = new ResizeObserver(read);
1484
+ observer.observe(header);
1485
+ return () => observer.disconnect();
1486
+ /* `compactNarrow` is a dependency, not decoration: crossing the width
1487
+ breakpoint takes the pin OFF the toolbar without changing its height, so
1488
+ the ResizeObserver never fires and a stale 124px would go on offsetting
1489
+ the box headers below a toolbar that is no longer there. */
1490
+ }, [compact, compactNested, compactStickyBoxHeaders, compactNarrow, compactWrapNode, options]);
1082
1491
 
1083
1492
  // Global label-column sizing: size the label column to the WIDEST field label
1084
1493
  // across the whole form, clamped to [MIN, MAX], and publish it as a CSS var on
@@ -1179,7 +1588,6 @@ const FormEngineImpl = ({
1179
1588
  }
1180
1589
  });
1181
1590
 
1182
- const compactNarrow = !!compactWrapWidth && compactWrapWidth < 480;
1183
1591
  // Info panels auto-open on Tier-1 content; the per-row user override sticks.
1184
1592
  const [infoPanelOverrides, setInfoPanelOverrides] = useState<Record<string, boolean>>({});
1185
1593
  // Toolbar filters affect the listed rows only — the meter reflects the full set.
@@ -1198,6 +1606,32 @@ const FormEngineImpl = ({
1198
1606
  // Track the last value we emitted via onChange so we can skip re-applying fixOptions
1199
1607
  // when the parent echoes it back as the new value prop (controlled component loop prevention)
1200
1608
  const lastEmittedValue = useRef<TQorusForm | TQorusFlatForm | undefined>(value);
1609
+ /**
1610
+ * A `localValue` that is only a MIRROR of the incoming `value`, and so must
1611
+ * never be emitted back as though this form had answered something.
1612
+ *
1613
+ * `localValue` is derived state: the sync-down effect below rebuilds it from
1614
+ * the `value` prop. When that rebuild adds nothing (`fixOptions` had no
1615
+ * default to materialise), the result says exactly what the parent already
1616
+ * holds, and emitting it is not a change — it is this form repeating the
1617
+ * parent's own words back at it.
1618
+ *
1619
+ * That repetition is harmless in a form that owns its state outright and
1620
+ * fatal in one that shares a parent with a NESTED engine. The two write to
1621
+ * the same parent value, so each sees the other's write arrive as an external
1622
+ * change; `lastEmittedValue` is then always one step behind and its guard
1623
+ * never fires. The engines alternate between two states forever, several
1624
+ * times a second, and the newer write is repeatedly overwritten by the older
1625
+ * one. Measured in the test editor: the row's summary flipped between two
1626
+ * readings indefinitely and `setTimeout` was starved past a 45-second
1627
+ * deadline.
1628
+ *
1629
+ * Only a mirror that CHANGES nothing is held back. A rebuild that restores a
1630
+ * required or preselected field's default still emits, because that default
1631
+ * is genuinely new information the parent has to be told about — which is the
1632
+ * whole reason the sync-down runs `fixOptions` at all.
1633
+ */
1634
+ const mirroredValue = useRef<TQorusForm | TQorusFlatForm | undefined>(undefined);
1201
1635
 
1202
1636
  if (originalValue.current === undefined && size(value)) {
1203
1637
  originalValue.current = localValue.fields;
@@ -1208,7 +1642,13 @@ const FormEngineImpl = ({
1208
1642
  const templates = useTemplates(allowTemplates, rest.stringTemplates, interfaceContext);
1209
1643
 
1210
1644
  useEffect(() => {
1211
- if (isEqual(localValue.fields, value)) {
1645
+ if (
1646
+ !shouldEmitLocalValue({
1647
+ localValue: localValue.fields,
1648
+ value,
1649
+ mirroredValue: mirroredValue.current,
1650
+ })
1651
+ ) {
1212
1652
  return;
1213
1653
  }
1214
1654
 
@@ -1371,9 +1811,12 @@ const FormEngineImpl = ({
1371
1811
  // Note: compare fixedValue against value, not localValue.fields — localValue may have been
1372
1812
  // updated by nested FormEngine emissions, so comparing against it would never skip.
1373
1813
  const normalizedValue = normalizeEmptyFieldValues(value);
1814
+ /* What this rebuild lets us agree with the parent on — `undefined` when it
1815
+ contributed something the parent has yet to learn. */
1816
+ const agreed = agreementFromRebuild(fixedValue, value);
1374
1817
  if (
1375
1818
  isEqual(normalizedValue, normalizeEmptyFieldValues(lastEmittedValue.current)) &&
1376
- isEqual(normalizeEmptyFieldValues(fixedValue), normalizedValue)
1819
+ agreed !== undefined
1377
1820
  ) {
1378
1821
  return;
1379
1822
  }
@@ -1382,14 +1825,42 @@ const FormEngineImpl = ({
1382
1825
  originalValue.current = fixedValue;
1383
1826
  }
1384
1827
 
1828
+ /* Remember a mirror so the emit effect can tell one from an answer, and
1829
+ record the agreement it represents. A rebuild that DID add something
1830
+ leaves both alone — the parent has to learn about it. */
1831
+ mirroredValue.current = agreed;
1832
+ if (agreed !== undefined) {
1833
+ lastEmittedValue.current = agreed;
1834
+ }
1835
+
1385
1836
  setLocalValue?.({ fields: fixedValue, meta: undefined });
1386
1837
  }, [JSON.stringify(options), JSON.stringify(value), isRendererOnly]);
1387
1838
 
1839
+ /**
1840
+ * Fields the reader has actually edited in this instance.
1841
+ *
1842
+ * "This field is required" is an ERROR message, and an error is a report that
1843
+ * something went wrong. Under a field nobody has been in yet it reports
1844
+ * nothing — the form is empty because it is new — while the requirement is
1845
+ * already stated by the asterisk, by the Needs-attention box the row sits in,
1846
+ * and by the completion meter. A form whose first act is to accuse the reader
1847
+ * of a mistake they have not made teaches them to discount its warnings.
1848
+ *
1849
+ * So the message waits for a touch. Being IN the field and leaving it empty
1850
+ * is a real gap, and that one shows. A ref, not state: every edit re-renders
1851
+ * anyway, so the following render reads the current set with no second pass.
1852
+ */
1853
+ const touchedOptionsRef = useRef<Set<string>>(new Set());
1854
+
1388
1855
  const handleValueChange = useCallback(
1389
1856
  (optionName: string, val?: any, _type?: string, isFunction?: boolean) => {
1857
+ // Every route into this handler is a person acting: typing, picking from a
1858
+ // menu, clearing the value, adding an optional field. Marked here rather
1859
+ // than inside the updater below, which React may invoke twice.
1860
+ touchedOptionsRef.current.add(optionName);
1390
1861
  setLocalValue(({ fields = {} }) => {
1391
1862
  const schemaType = getOptionSchemaStorageType(options?.[optionName], isRendererOnly);
1392
- const isAnyLike = schemaType === 'any' || schemaType === 'auto';
1863
+ const isAnyLike = isUntypedOptionType(schemaType);
1393
1864
  // For any/auto schema types, preserve the user's chosen type stored in the field
1394
1865
  const resolvedSchemaType =
1395
1866
  isAnyLike && (fields[optionName] as IQorusFormField)?.type ?
@@ -1441,7 +1912,19 @@ const FormEngineImpl = ({
1441
1912
  },
1442
1913
  };
1443
1914
 
1444
- if (isFunction) {
1915
+ /* The flag belongs to the VALUE, so a change that said nothing about it
1916
+ does not throw it away — `undefined` is no opinion, not a denial.
1917
+ Deleting it unconditionally left the AST on the row with nothing
1918
+ marking it as one: an assertion's Expected Value came back from a
1919
+ saved draft drawn as a two-field hash of `exp` and `args`, because
1920
+ merely opening the row and reverting had stripped the flag. */
1921
+ if (
1922
+ shouldMarkAsExpression(
1923
+ isFunction,
1924
+ val,
1925
+ ((fields as TQorusForm)[optionName] as { is_expression?: boolean })?.is_expression
1926
+ )
1927
+ ) {
1445
1928
  (updatedValue[optionName] as { is_expression?: boolean }).is_expression = true;
1446
1929
  } else {
1447
1930
  delete updatedValue[optionName].is_expression;
@@ -1455,9 +1938,24 @@ const FormEngineImpl = ({
1455
1938
  val !== (fields as TQorusForm)[optionName]?.value
1456
1939
  ) {
1457
1940
  forEach(options, (option, depName) => {
1941
+ /* A dependency is `name`, `name=value` or `name!=value`, so the
1942
+ entry has to be PARSED to find which option it names. Compared as
1943
+ a whole string, only the bare spelling ever matched — so a field
1944
+ declared `depends_on: ['subject_iface_kind=workflow']` was never
1945
+ cleared when that option changed.
1946
+
1947
+ What that cost: a test whose subject was switched from a workflow
1948
+ to a service kept the workflow's `Subject Interface Version`.
1949
+ Services and jobs are versioned but only the latest is testable,
1950
+ so the field no longer applied, yet it stayed on the form as a
1951
+ locked control in front of a value the author could not clear —
1952
+ the value being there is exactly what stops the field being
1953
+ withheld outright. */
1458
1954
  if (
1459
1955
  option.depends_on &&
1460
- flatten(option.depends_on).includes(optionName) &&
1956
+ flatten(option.depends_on).some(
1957
+ (dependency) => parseDependency(dependency as string).name === optionName
1958
+ ) &&
1461
1959
  updatedValue[depName]
1462
1960
  ) {
1463
1961
  updatedValue[depName].value = undefined;
@@ -1637,6 +2135,7 @@ const FormEngineImpl = ({
1637
2135
  return newValue;
1638
2136
  }
1639
2137
 
2138
+
1640
2139
  const rendererType = getType(
1641
2140
  (options[optionName].ui_type || options[optionName].type) as TQorusType,
1642
2141
  operators,
@@ -1648,7 +2147,8 @@ const FormEngineImpl = ({
1648
2147
  options,
1649
2148
  operators,
1650
2149
  (option as IQorusFormField)?.op,
1651
- isRendererOnly
2150
+ isRendererOnly,
2151
+ optionHoldsExpression(option)
1652
2152
  );
1653
2153
 
1654
2154
  if (!isPlainObject(option)) {
@@ -1693,6 +2193,27 @@ const FormEngineImpl = ({
1693
2193
  return { members, satisfiedBy };
1694
2194
  }, [JSON.stringify(options), JSON.stringify(availableOptions)]);
1695
2195
 
2196
+ /* One bag per field, rebuilt only when something it is made of changes — the
2197
+ schema that declares the `inherit_props`, the sibling values they resolve
2198
+ against, or the scope handed in from an outer form. Held per field rather
2199
+ than per form because each field's bag is different: `inherit_props` is
2200
+ declared on the field. See `inheritedScopeFor` for what a churning bag
2201
+ costs the field that receives it. */
2202
+ const inheritedScopes = useMemo(() => {
2203
+ const scopes = new Map<string, Record<string, unknown>>();
2204
+ for (const optionName of Object.keys(options ?? {})) {
2205
+ scopes.set(
2206
+ optionName,
2207
+ inheritedScopeFor(
2208
+ options?.[optionName]?.inherit_props,
2209
+ availableOptions,
2210
+ inheritedFromParent
2211
+ )
2212
+ );
2213
+ }
2214
+ return scopes;
2215
+ }, [options, availableOptions, inheritedFromParent]);
2216
+
1696
2217
  // Dependency linkage (3a): when filling a dependency unlocks rows, flash
1697
2218
  // them once — the form visibly "opens up" instead of silently changing.
1698
2219
  const dependencyLockedNames = useMemo(() => {
@@ -1722,6 +2243,49 @@ const FormEngineImpl = ({
1722
2243
  }
1723
2244
  }, [dependencyLockedNames.join('|')]);
1724
2245
 
2246
+ /* The same thing one level down: a CHOICE that becomes available is as
2247
+ invisible as a field that becomes editable, and it is worse, because it
2248
+ opens up inside a control the reader may not have open. So the field that
2249
+ offers it flashes, which is the affordance that says "look here" without
2250
+ claiming the choice was made.
2251
+
2252
+ Keyed by field and by the value's identity, so a value that merely moves in
2253
+ the list is not mistaken for one that opened. Fields whose values gate on
2254
+ nothing never enter the set, which is nearly all of them. */
2255
+ const lockedAllowedValues = useMemo(() => {
2256
+ const locked: string[] = [];
2257
+ forEach(options || {}, (optionSchema, name) => {
2258
+ forEach(optionSchema?.allowed_values || [], (allowedValue) => {
2259
+ if (!getAllowedValueAvailability(allowedValue, availableOptions, options || {}).available) {
2260
+ locked.push(`${name}\u0000${JSON.stringify(allowedValue?.value?.value)}`);
2261
+ }
2262
+ });
2263
+ });
2264
+ return locked;
2265
+ }, [JSON.stringify(options), JSON.stringify(availableOptions)]);
2266
+ const previousLockedAllowedValues = useRef<string[] | null>(null);
2267
+ useEffect(() => {
2268
+ const previous = previousLockedAllowedValues.current;
2269
+ previousLockedAllowedValues.current = lockedAllowedValues;
2270
+ if (!previous) {
2271
+ return;
2272
+ }
2273
+ const opened = previous
2274
+ .filter((key) => !lockedAllowedValues.includes(key))
2275
+ .map((key) => key.split('\u0000')[0]);
2276
+ const fields = uniq(opened).filter(
2277
+ (name) =>
2278
+ !!options?.[name] &&
2279
+ !options?.[name]?.disabled &&
2280
+ // A field that is itself still locked flashes for its OWN unlocking, in
2281
+ // the effect above; two flashes for one change is a flicker, not a cue.
2282
+ !dependencyLockedNames.includes(name)
2283
+ );
2284
+ if (fields.length) {
2285
+ flashOptions(fields);
2286
+ }
2287
+ }, [lockedAllowedValues.join('|')]);
2288
+
1725
2289
  // The not-yet-added optional fields: everything in the schema the form is not
1726
2290
  // already showing a row for. This ONE list feeds all three ways a field gets
1727
2291
  // added — the inline addable rows, the Fields menu, and its "Select all" — so
@@ -1793,7 +2357,8 @@ const FormEngineImpl = ({
1793
2357
  options,
1794
2358
  operators,
1795
2359
  (option as IQorusFormField).op,
1796
- isRendererOnly
2360
+ isRendererOnly,
2361
+ optionHoldsExpression(option)
1797
2362
  );
1798
2363
  const optionValue = (option as IQorusFormField).value;
1799
2364
 
@@ -1864,7 +2429,8 @@ const FormEngineImpl = ({
1864
2429
  options,
1865
2430
  operators,
1866
2431
  (option as IQorusFormField).op,
1867
- isRendererOnly
2432
+ isRendererOnly,
2433
+ optionHoldsExpression(option)
1868
2434
  ),
1869
2435
  (option as IQorusFormField).value
1870
2436
  )
@@ -1913,7 +2479,8 @@ const FormEngineImpl = ({
1913
2479
  options,
1914
2480
  operators,
1915
2481
  (availableOptions as TQorusForm)?.[name]?.op,
1916
- isRendererOnly
2482
+ isRendererOnly,
2483
+ optionHoldsExpression((availableOptions as TQorusForm)?.[name])
1917
2484
  );
1918
2485
  const value = (availableOptions as TQorusForm)?.[name]?.value;
1919
2486
  // A schema-declared hash whose every entry is a materialised-but-unset
@@ -2064,11 +2631,16 @@ const FormEngineImpl = ({
2064
2631
  if (!names.length) {
2065
2632
  return;
2066
2633
  }
2067
- hasAppliedInitialExpansionRef.current = true;
2068
2634
  const toExpand = initialExpandedOptions.filter((name) => names.includes(name));
2069
2635
  if (!toExpand.length) {
2636
+ // Not "there is no such row" — "not YET". A server-driven schema arrives
2637
+ // in pieces, and the row the caller named can be in a later one (a test's
2638
+ // `cases` field materialises once its step-kind catalogue lands). Latching
2639
+ // here would spend the one shot on a form that did not yet contain the
2640
+ // field, and the address would never be applied at all.
2070
2641
  return;
2071
2642
  }
2643
+ hasAppliedInitialExpansionRef.current = true;
2072
2644
  setExpandedOptions((prev) => [...prev, ...toExpand.filter((name) => !prev.includes(name))]);
2073
2645
  }, [initialExpandedOptions, compact, availableOptions]);
2074
2646
 
@@ -2089,15 +2661,13 @@ const FormEngineImpl = ({
2089
2661
  // genuine remount (a fresh instance) auto-focuses again, which is the intended
2090
2662
  // on-mount behaviour.
2091
2663
  const hasAutoFocusedRef = useRef(false);
2092
- // The field expanded programmatically for autofocus. A ref (not state) so
2093
- // CompactRow's 60ms focus timer reads the current value regardless of render
2094
- // batching; CompactRow focuses this one with `preventScroll` so an off-screen
2095
- // (or below-the-fold) form is never scrolled into view on mount.
2096
- const autoFocusNameRef = useRef<string | undefined>(undefined);
2097
2664
  useEffect(() => {
2098
- // Two decisions, one scan. `autoFocusFirstRequired` opens the row AND puts
2099
- // the caret in it; `expandFirstRequired` only opens it. Setting both keeps
2100
- // the focusing behaviour, since focusing implies opening.
2665
+ // Two decisions, one scan. Both open the row that needs attention;
2666
+ // `autoFocusFirstRequired` additionally promises the caret lands in it, and
2667
+ // so refuses to run at all while the reader holds the caret somewhere else
2668
+ // (the guard below). An opened row takes the caret either way — that is
2669
+ // CompactRow's tap-to-edit rule, and it declines just as firmly to take one
2670
+ // that is already somewhere outside the row.
2101
2671
  const wantsFocus = !!autoFocusFirstRequired;
2102
2672
  if ((!wantsFocus && !expandFirstRequired) || !compact || !options) {
2103
2673
  return;
@@ -2151,15 +2721,16 @@ const FormEngineImpl = ({
2151
2721
  hasAutoFocusedRef.current = true;
2152
2722
 
2153
2723
  if (target) {
2154
- // Set the ref before the state update so CompactRow's focus timer sees it.
2155
- // Expand-only leaves it unset, which is what keeps the caret where it is.
2156
- if (wantsFocus) {
2157
- autoFocusNameRef.current = target;
2158
- }
2159
2724
  setExpandedOptions((prev) =>
2160
2725
  prev.includes(target) ? prev
2161
2726
  : expandMode === 'multi' ? [...prev, target]
2162
- : [target]
2727
+ // `expandMode: 'single'` is a rule about what a CLICK does: opening a
2728
+ // row closes the one the reader last opened. It is not a licence to
2729
+ // close a row the HOST named through `initialExpandedOptions`. That
2730
+ // is an address — a statement about what the page is FOR, made before
2731
+ // the reader did anything — and this scan is a convenience layered on
2732
+ // top of it. Keep the named rows; replace only what a click could.
2733
+ : [...prev.filter((name) => initialExpandedOptions?.includes(name)), target]
2163
2734
  );
2164
2735
  }
2165
2736
  }, [
@@ -2172,13 +2743,18 @@ const FormEngineImpl = ({
2172
2743
  dependencyLockedNames,
2173
2744
  getOptionBucket,
2174
2745
  expandMode,
2746
+ initialExpandedOptions,
2175
2747
  ]);
2176
2748
 
2177
2749
  // Read-first completion summary (how many shown options have a value set),
2178
2750
  // surfaced as a progress meter at the top of the compact form.
2179
2751
  const readFirstCompletion = useMemo(
2180
- () => getReadFirstCompletion(availableOptions as Record<string, IQorusFormField | undefined>),
2181
- [JSON.stringify(availableOptions)]
2752
+ () =>
2753
+ getReadFirstCompletion(
2754
+ availableOptions as Record<string, IQorusFormField | undefined>,
2755
+ (name) => getOptionBucket(name) === 'attention'
2756
+ ),
2757
+ [JSON.stringify(availableOptions), getOptionBucket]
2182
2758
  );
2183
2759
 
2184
2760
  const optionalFields = useMemo(
@@ -2272,13 +2848,59 @@ const FormEngineImpl = ({
2272
2848
  // schema has not arrived yet (`type` undefined) instead of crashing.
2273
2849
  const optionSchema = options?.[optionName];
2274
2850
  const schemaUiType = optionSchema?.ui_type as TQorusType;
2275
- const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
2851
+ const uiTypeIsAnyLike = isUntypedOptionType(schemaUiType);
2852
+ /* An EXPRESSION's stored type is skipped in this chain.
2853
+
2854
+ The value is `{ is_expression: true, value: {...} }`, so the type saved
2855
+ beside it describes that envelope — `hash` — not what the field holds.
2856
+ With no `ui_type` to win first, that `hash` beat the schema's `auto`
2857
+ and was handed to the expression editor as the return type to check
2858
+ against: `1 + 2` on a field declared `auto` came back "This does not
2859
+ fit. The expression returns int, and this field holds hash". A literal
2860
+ `1` was fine, because a literal is stored as a plain value whose type
2861
+ matches.
2862
+
2863
+ Dropping the stored type here lets the schema decide, which is the
2864
+ only party that knows what the field accepts. This is the row
2865
+ renderer's OWN resolution and does not go through
2866
+ `getOptionFieldStorageType`, so guarding that one alone left this
2867
+ untouched. */
2868
+ const storedRowType = optionHoldsExpression(other) ? undefined : type;
2869
+
2870
+ /* A RELOADED expression is handed to the editor in the shape the editor
2871
+ was written against.
2872
+
2873
+ An expression is written as `{ type, is_expression: true, value: ast }`
2874
+ and saved as `{ type, value: { is_expression: true, value: ast } }` —
2875
+ the flag moves ONTO the value. The editor was only ever given
2876
+ `other.is_expression`, which is absent in the saved shape, so a
2877
+ reloaded expression was not recognised as one: an `auto` field then
2878
+ resolved its type from the value it could see, decided the envelope was
2879
+ a `hash`, and rendered `1 + 2` as a raw tree of `is_expression` /
2880
+ `exp` / `args` for the author to edit by hand.
2881
+
2882
+ Both halves have to move together. Flipping the flag alone would put
2883
+ the editor into expression mode and then hand it the ENVELOPE where it
2884
+ expects the expression, so the value is unwrapped here too — after
2885
+ which a saved expression is indistinguishable from one just typed,
2886
+ which is what every component downstream already assumes.
2887
+
2888
+ Reported as "after Run assertion the value shows as raw data", but the
2889
+ run is incidental: the field beside it, which no run touched, was
2890
+ equally raw. The trigger is a RELOAD, which is the only thing that puts
2891
+ an expression into its saved shape. */
2892
+ const nestedExpression = !!(other as { value?: { is_expression?: unknown } })?.value
2893
+ ?.is_expression;
2894
+ const expressionAwareValue =
2895
+ nestedExpression ?
2896
+ (other as unknown as { value: { value: unknown } }).value.value
2897
+ : other.value;
2276
2898
  const resolvedType =
2277
2899
  (isFixedCompactAllowedValueOption(optionSchema) ?
2278
2900
  getOptionSchemaStorageType(optionSchema, isRendererOnly)
2279
2901
  : schemaUiType && !uiTypeIsAnyLike ? schemaUiType
2280
2902
  : undefined) ||
2281
- type ||
2903
+ storedRowType ||
2282
2904
  schemaUiType ||
2283
2905
  (optionSchema?.type as TQorusType) ||
2284
2906
  'any';
@@ -2411,20 +3033,23 @@ const FormEngineImpl = ({
2411
3033
  // `inherit_props: { language: 'language' }`, the list renderer
2412
3034
  // forwards it into each row, and the row's body resolves against
2413
3035
  // the accumulated bag.
2414
- inheritedFromParent={{
2415
- ...inheritedFromParent,
2416
- ...resolveInheritProps(
2417
- options?.[optionName]?.inherit_props,
2418
- availableOptions,
2419
- inheritedFromParent
2420
- ),
2421
- }}
3036
+ //
3037
+ // Read from the memoised map rather than built here: this is a
3038
+ // PROP, and one built inline is a new object on every render of
3039
+ // the form, which re-renders every field that receives it. The
3040
+ // `??` chain answers for a field the schema does not describe —
3041
+ // `inheritedScopeFor` returns the SAME references, so an
3042
+ // undescribed field is as stable as a described one.
3043
+ inheritedFromParent={
3044
+ inheritedScopes.get(optionName) ?? inheritedFromParent ?? NO_INHERITED_SCOPE
3045
+ }
2422
3046
  // Propagate compact so an arg_schema field renders a COMPACT sub-form
2423
3047
  // (consistent with the parent) rather than the classic FormEngine.
2424
3048
  compact={compact}
2425
3049
  // SEAM: forwarded through TemplateField's rest-spread to AutoFormField,
2426
3050
  // which renders consumer-injected editors by field type/ui_type.
2427
3051
  componentOverrides={componentOverrides}
3052
+ templateAwareUiTypes={templateAwareUiTypes}
2428
3053
  allowTemplates={!!(allowTemplates && options?.[optionName]?.supports_templates)}
2429
3054
  allowFunctions={!!options?.[optionName]?.supports_expressions}
2430
3055
  // reqraft: form-level expression fields get the Visual/Text shell
@@ -2441,7 +3066,25 @@ const FormEngineImpl = ({
2441
3066
  isFixedCompactAllowedValueOption(optionSchema) ||
2442
3067
  (options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any')
2443
3068
  }
2444
- templates={templates.value}
3069
+ // A field may declare a template list OF ITS OWN, and when it does
3070
+ // that list is the one to offer. The engine-wide `stringTemplates`
3071
+ // are the form's shared vocabulary - config items, system properties
3072
+ // - and every field draws on the same set. A per-field list answers a
3073
+ // question only that field asks: a test assertion's `$.` references
3074
+ // are the values ITS case captured, and no other field in the form
3075
+ // should be offering them.
3076
+ //
3077
+ // Passing the engine's list unconditionally overwrote the schema's,
3078
+ // so a field that declared templates had none by the time it
3079
+ // rendered. On a typed field that only cost the picker; on an
3080
+ // ANY-LIKE one it changed the question, because `TemplateField`
3081
+ // opens on the template selector only when there is something to
3082
+ // pick and otherwise falls back to the type picker. An untyped field
3083
+ // therefore asked the author to choose `string`/`int`/`hash` before
3084
+ // it would let them name a value they had already captured.
3085
+ templates={
3086
+ (optionSchema as TFieldWithOwnTemplates | undefined)?.templates ?? templates.value
3087
+ }
2445
3088
  {...getTypeAndCanBeNull(
2446
3089
  // The RENDERER type picks the editor — the storage type lives on
2447
3090
  // the value envelope. Passing storage here rendered a `richtext`
@@ -2462,8 +3105,8 @@ const FormEngineImpl = ({
2462
3105
  key={optionName}
2463
3106
  arg_schema={options?.[optionName]?.arg_schema}
2464
3107
  noSoft={!!rest?.options}
2465
- value={other.value}
2466
- isFunction={(other as { is_expression?: boolean }).is_expression}
3108
+ value={expressionAwareValue}
3109
+ isFunction={optionHoldsExpression(other)}
2467
3110
  isDefaultFunction={options?.[optionName]?.default_view === 'expression'}
2468
3111
  sensitive={options?.[optionName]?.sensitive}
2469
3112
  default_value={getDefaultValue(options?.[optionName])}
@@ -2487,7 +3130,7 @@ const FormEngineImpl = ({
2487
3130
  readOnly={readOnly}
2488
3131
  size={editorSize || rest.size}
2489
3132
  menuItems={
2490
- (options?.[optionName] as any)?.ui_type === 'any' ?
3133
+ offersTypeChoices(options?.[optionName], anyLikeUiTypes) ?
2491
3134
  getCustomMenuTemplateItems(optionName)
2492
3135
  : undefined
2493
3136
  }
@@ -2500,6 +3143,7 @@ const FormEngineImpl = ({
2500
3143
  name={optionName}
2501
3144
  option={{ type: resolvedType, ...other }}
2502
3145
  getType={getTypeForOption}
3146
+ untouched={!touchedOptionsRef.current.has(optionName)}
2503
3147
  />
2504
3148
  {operators && size(operators) && size(other.op) ?
2505
3149
  <>
@@ -2549,6 +3193,8 @@ const FormEngineImpl = ({
2549
3193
  handleRemoveOperator,
2550
3194
  getCustomMenuTemplateItems,
2551
3195
  getTypeForOption,
3196
+ inheritedScopes,
3197
+ inheritedFromParent,
2552
3198
  ]
2553
3199
  );
2554
3200
 
@@ -2581,6 +3227,7 @@ const FormEngineImpl = ({
2581
3227
  () => ({
2582
3228
  templates: templates.value,
2583
3229
  readOnly,
3230
+ onReadOnlyActivate,
2584
3231
  commitMode,
2585
3232
  expandMode,
2586
3233
  options,
@@ -2590,7 +3237,6 @@ const FormEngineImpl = ({
2590
3237
  codePreviewRenderer,
2591
3238
  showAllDescriptions,
2592
3239
  expandedOptions,
2593
- autoFocusNameRef,
2594
3240
  highlightedOptions,
2595
3241
  flashedOptions,
2596
3242
  infoPanelOverrides,
@@ -2638,7 +3284,6 @@ const FormEngineImpl = ({
2638
3284
  codePreviewRenderer,
2639
3285
  showAllDescriptions,
2640
3286
  expandedOptions,
2641
- autoFocusNameRef,
2642
3287
  highlightedOptions,
2643
3288
  flashedOptions,
2644
3289
  infoPanelOverrides,
@@ -2698,7 +3343,21 @@ const FormEngineImpl = ({
2698
3343
  fields: false,
2699
3344
  help: false,
2700
3345
  },
2701
- hasMultipleOptions: size(availableOptions) > 1,
3346
+ /* From the SCHEMA, not from the value.
3347
+ *
3348
+ * `availableOptions` is keyed off the form's VALUE, so it grows as data
3349
+ * arrives — and the search row is rendered only when this is true
3350
+ * (`CompactToolbar`). A form whose value lands after its first render
3351
+ * therefore ADDED a 38px row to its header a beat later, and everything
3352
+ * below it moved. Measured on the Qorus IDE's test editor, on the nested
3353
+ * sub-form of a case: 33 elements jumped down 51px in one frame, on every
3354
+ * cold load.
3355
+ *
3356
+ * How many fields a form has is a property of its schema. The two agree
3357
+ * once the value has arrived — `fixOptions` materialises an entry per
3358
+ * declared field — so this only changes the answer during the load, which
3359
+ * is the whole point. */
3360
+ hasMultipleOptions: size(options) > 1,
2702
3361
  compactQuery,
2703
3362
  setCompactQuery,
2704
3363
  requiredOnly,
@@ -2719,6 +3378,7 @@ const FormEngineImpl = ({
2719
3378
  }),
2720
3379
  [
2721
3380
  readOnly,
3381
+ onReadOnlyActivate,
2722
3382
  validityData,
2723
3383
  readFirstAttentionCount,
2724
3384
  readFirstCompletion,
@@ -2949,6 +3609,13 @@ const FormEngineImpl = ({
2949
3609
  const bucketCount = (b: TFormEngineBoxKey) =>
2950
3610
  bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2951
3611
 
3612
+ /* The default is to collapse the Optional box, and an explicit pass is
3613
+ what waives the "never collapse the sole box" escape below. Resolving it
3614
+ here rather than in the parameter list is what makes the two
3615
+ distinguishable — `['optional']` as a default and `['optional']` from a
3616
+ caller mean different things. */
3617
+ const compactCollapsedGroups = compactCollapsedGroupsProp ?? ['optional'];
3618
+ const collapsedGroupsRequested = compactCollapsedGroupsProp !== undefined;
2952
3619
  // "Is the Optional box the whole form?" — when nothing needs attention and
2953
3620
  // nothing is set, collapsing it leaves a card with no visible content at all.
2954
3621
  const onlyOptionalRows =
@@ -2985,15 +3652,28 @@ const FormEngineImpl = ({
2985
3652
  const boxHasPreselectedRows = (boxKey: TFormEngineBoxKey) =>
2986
3653
  boxKey === 'optional' &&
2987
3654
  bucketGroups[boxKey].some((groupName) => groupHasPreselectedRow(boxKey, groupName));
3655
+ /* The pin, and where it lands. A nested sub-form sits inside the parent's
3656
+ card and owns no scroll context, so it never pins. */
3657
+ const stickyBoxHeaders = compactStickyBoxHeaders && !compactNested;
3658
+ const boxStickyOffset = compactStickyOffset + compactToolbarHeight;
2988
3659
  const STATUS_BOXES: Array<{
2989
3660
  key: TFormEngineBoxKey;
2990
3661
  label: string;
2991
3662
  intent?: 'warning' | 'success';
2992
3663
  icon: IReqoreIconName;
2993
3664
  }> = [
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' },
3665
+ {
3666
+ key: 'attention',
3667
+ label: compactBoxLabels?.attention ?? 'Needs attention',
3668
+ intent: 'warning',
3669
+ icon: 'ErrorWarningLine',
3670
+ },
3671
+ { key: 'set', label: compactBoxLabels?.set ?? 'Set', intent: 'success', icon: 'CheckLine' },
3672
+ {
3673
+ key: 'optional',
3674
+ label: compactBoxLabels?.optional ?? 'Optional',
3675
+ icon: 'CheckboxBlankCircleLine',
3676
+ },
2997
3677
  ];
2998
3678
 
2999
3679
  // Build the rows for one group: contiguous required-group members are pulled
@@ -3137,7 +3817,7 @@ const FormEngineImpl = ({
3137
3817
  // Mirrors the toolbar's own search-row gate: when no search
3138
3818
  // row renders, the header is a thin strip and sits tight to
3139
3819
  // the first status box (see StyledCompactPanel).
3140
- $tightHeader={!(compactToolbarParts?.search && size(availableOptions) > 1)}
3820
+ $tightHeader={!(compactToolbarParts?.search && size(options) > 1)}
3141
3821
  // The top-level form scrolls, so its toolbar STICKS and carries a
3142
3822
  // dark blurred backdrop so content ghosts cleanly beneath it. A
3143
3823
  // nested (arg_schema) sub-form owns no scroll context — drop the
@@ -3145,6 +3825,9 @@ const FormEngineImpl = ({
3145
3825
  // transparent inside the parent's card.
3146
3826
  $headerBg={compactNested ? 'transparent' : headerBg}
3147
3827
  $nested={compactNested}
3828
+ // Keeps the wrapper from clipping, which is what a pinned box
3829
+ // header inside it needs — see StyledCompactPanel.
3830
+ $stickyBoxes={compactStickyBoxHeaders && !compactNested}
3148
3831
  flat
3149
3832
  raised
3150
3833
  minimal
@@ -3153,7 +3836,30 @@ const FormEngineImpl = ({
3153
3836
  // parent's edit card) instead of stacking its own dark surface.
3154
3837
  // The status boxes keep their own tints; the sticky toolbar keeps
3155
3838
  // its blurred header via the $headerBg override.
3156
- stickyHeader={!compactNested}
3839
+ // NARROW: the toolbar gives up its pin so the box headers
3840
+ // can keep theirs.
3841
+ //
3842
+ // Measured at 380px, a pinned toolbar is 124px and a pinned
3843
+ // box header is 48 — together a third of a phone's readable
3844
+ // body, before the form has drawn a line. Only one of them
3845
+ // can be afforded there, and the box header is the better
3846
+ // buy: the toolbar is chrome you GO to (a search, a field
3847
+ // picker, a meter — all of it still one scroll away at the
3848
+ // top of the form), while the box header is chrome you READ,
3849
+ // and it is the only thing that says which bucket the rows
3850
+ // under the pointer are in.
3851
+ //
3852
+ // Same shape of decision the engine already makes one level
3853
+ // down, where `optionActionsCollapse: 'auto'` folds a row's
3854
+ // actions into a menu because a narrow row has no width for
3855
+ // a button strip.
3856
+ //
3857
+ // A consumer can still force either way: `compactPanelProps`
3858
+ // spreads after this.
3859
+ stickyHeader={!compactNested && !(compactNarrow && compactStickyBoxHeaders)}
3860
+ // Below the host's own pinned chrome, if it has any — see
3861
+ // `compactStickyOffset`.
3862
+ stickyHeaderOffset={compactStickyOffset || undefined}
3157
3863
  // Consumer overrides land last so they win over every default
3158
3864
  // above; `actions` and `contentStyle` below merge rather than
3159
3865
  // replace (see `compactPanelProps`).
@@ -3163,6 +3869,26 @@ const FormEngineImpl = ({
3163
3869
  display: 'flex',
3164
3870
  flexFlow: 'column',
3165
3871
  gap: '10px',
3872
+ /* The panel body must not be a SCROLL CONTAINER.
3873
+
3874
+ `StyledPanelContent` ships `overflow: auto` so a
3875
+ height-capped panel can scroll its body. This one is never
3876
+ capped — in `compactScroll: 'host'` the page scrolls and in
3877
+ 'own' the wrap above does — so the `auto` never engages and
3878
+ reads as inert. It is not: an `overflow: auto` box is a
3879
+ scrollport whether or not it scrolls, and every
3880
+ `position: sticky` inside resolves against IT. The status
3881
+ box headers (and the toolbar, in a nested host) would pin
3882
+ to a box that never moves, which looks exactly like sticky
3883
+ being ignored.
3884
+
3885
+ `overflow-x: clip`, NOT `hidden`, for the reason spelled
3886
+ out on `StyledCompactWrap`: CSS coerces an `overflow-y:
3887
+ visible` to `auto` unless the other axis is visible or
3888
+ clip, which would put the scrollport straight back. `clip`
3889
+ keeps the horizontal guard the row ellipsis relies on. */
3890
+ overflowY: 'visible',
3891
+ overflowX: 'clip',
3166
3892
  ...compactPanelProps?.contentStyle,
3167
3893
  }}
3168
3894
  >
@@ -3205,7 +3931,7 @@ const FormEngineImpl = ({
3205
3931
  const wouldCollapse =
3206
3932
  compactCollapsedGroups.includes(box.key) &&
3207
3933
  !query &&
3208
- !(box.key === 'optional' && onlyOptionalRows);
3934
+ !(box.key === 'optional' && onlyOptionalRows && !collapsedGroupsRequested);
3209
3935
  // Only a box that was opened SOLELY to reveal a preselected
3210
3936
  // row splits its body; a box that renders open anyway shows
3211
3937
  // all of its groups as before.
@@ -3265,6 +3991,10 @@ const FormEngineImpl = ({
3265
3991
  <StyledStatusBox
3266
3992
  $accent={accent}
3267
3993
  $bg={boxBg}
3994
+ // Only a box that PINS needs an opaque header (see
3995
+ // StyledStatusBox); passing it unconditionally would
3996
+ // repaint every form's boxes for nothing.
3997
+ $surface={stickyBoxHeaders ? getMainBackgroundColor(theme) : undefined}
3268
3998
  key={box.key}
3269
3999
  ref={(node: HTMLDivElement | null) => {
3270
4000
  statusBoxRefs.current[box.key] = node;
@@ -3276,6 +4006,12 @@ const FormEngineImpl = ({
3276
4006
  }
3277
4007
  flat
3278
4008
  minimal
4009
+ /* The header stays on screen while its own box is
4010
+ being read — see `compactStickyBoxHeaders`. Sticky
4011
+ is bounded by its containing block, so the header
4012
+ leaves with its box and only one is ever pinned. */
4013
+ stickyHeader={stickyBoxHeaders}
4014
+ stickyHeaderOffset={stickyBoxHeaders ? boxStickyOffset : undefined}
3279
4015
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
3280
4016
  collapsible
3281
4017
  // Which boxes start closed is the consumer's call
@@ -3446,37 +4182,25 @@ const FormEngineImpl = ({
3446
4182
  );
3447
4183
  };
3448
4184
 
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
- );
4185
+ /* Which of the seven waits is holding the form, in the order they are
4186
+ tested. Rendered as `data-wait` on the placeholder so a page can be asked
4187
+ what it is waiting FOR instead of only that it is waiting. */
4188
+ const waitReason =
4189
+ rest.skeleton ? (rest.skeletonReason ? `host:${rest.skeletonReason}` : 'host')
4190
+ : templates.loading ? 'templates'
4191
+ : typesLoading ? 'types'
4192
+ : optionsLoading ? 'options'
4193
+ : loading ? 'fetch'
4194
+ : operatorsUrl && !operators ? 'operators'
4195
+ : (url || customUrl) && !options ? 'schema'
4196
+ : undefined;
4197
+
4198
+ if (waitReason) {
4199
+ /* The shape of the FORM, not a generic block arrangement. This used to be
4200
+ three bars over three big panels, which resembles nothing this gate is
4201
+ waiting for and shared no vocabulary with the other waits on the same
4202
+ page — see `FormFieldsSkeleton`. */
4203
+ return <FormFieldsSkeleton fill className='options-loading-skeleton' reason={waitReason} />;
3480
4204
  }
3481
4205
 
3482
4206
  // A loader that rejected (and produced no usable schema) surfaces its error
@@ -3643,91 +4367,70 @@ const FormEngineImpl = ({
3643
4367
  STRECHABLE_TYPES.has(options[optionName].type as TQorusType) ||
3644
4368
  (options[optionName] as { stretch?: boolean }).stretch,
3645
4369
  size: 'small',
3646
- floatingActions: true,
3647
- actions: [
3648
- // SEAM (reqraft): per-option injected hover actions — where
3649
- // the IDE renders its `allowAi` AiAssistanceAction (with the
3650
- // option's schema as context). The consumer (the IDE) injects
3651
- // it; same factory pattern as the ExpressionBuilder's
3652
- // `extraActions`.
3653
- ...resolveOptionActions(optionActions, {
4370
+ /* No floating bar. It is portalled over whatever sits above the
4371
+ row — on the first row, the form's own header buttons, which it
4372
+ made unclickable — and a bar that only appears on a hover is out
4373
+ of reach on a phone. The row's actions render in its own
4374
+ header: injected ones as buttons (all of them in the menu on
4375
+ touch or a narrow viewport), the row's own secondary ones in
4376
+ its ⋯ menu. */
4377
+ actions: optionRowActions({
4378
+ injected: resolveOptionActions(optionActions, {
3654
4379
  name: optionName,
3655
4380
  schema: options[optionName],
3656
4381
  value: availableOptions?.[optionName] as TOption,
3657
- }),
3658
- {
3659
- size: 'tiny',
3660
- icon: 'FullscreenLine',
3661
- className: 'options-item-fullscreen',
3662
- tooltip: 'Focused Editing',
3663
- show:
3664
- (
3665
- !readOnly &&
3666
- type !== 'code-editor' &&
3667
- ((options[optionName] as any)?.ui_type || options[optionName]?.type) !==
3668
- 'code-editor'
3669
- ) ?
3670
- 'hover'
3671
- : false,
3672
- onClick: () => setFocusedEditing(optionName),
3673
- },
3674
- {
3675
- size: 'tiny',
3676
- icon: 'CloseLine',
3677
- className: 'options-item-remove',
3678
- tooltip: 'Remove Value',
3679
- show:
3680
- (
3681
- !readOnly &&
3682
- (other as any).value &&
3683
- !isEqual((other as any).value, getDefaultValue(options[optionName])) &&
3684
- !(options[optionName]?.disabled || (options[optionName] as any)?.readonly)
3685
- ) ?
3686
- 'hover'
3687
- : false,
3688
- onClick: () => handleValueChange(optionName, undefined),
3689
- },
3690
- {
3691
- size: 'tiny',
3692
- icon: 'HistoryLine',
3693
- className: 'options-item-revert',
3694
- tooltip: 'Revert Changes',
3695
- show:
3696
- !readOnly && hasOptionChanged((other as any).value, optionName) ?
3697
- 'hover'
3698
- : false,
3699
- onClick: () => {
3700
- handleValueChange(
3701
- optionName,
3702
- originalValue.current?.[optionName]?.value,
3703
- originalValue.current?.[optionName]?.type
3704
- );
3705
- },
3706
- },
3707
- {
3708
- size: 'tiny',
3709
- icon: 'DeleteBinLine',
3710
- intent: 'danger',
3711
- minimal: true,
3712
- className: 'options-optional-remove',
3713
- show:
3714
- (
3715
- !readOnly &&
3716
- !options[optionName]?.preselected &&
3717
- !options[optionName]?.required
3718
- ) ?
3719
- 'hover'
3720
- : false,
3721
- onClick: () => {
3722
- confirmAction({
3723
- title: 'Remove Selected Option',
3724
- onConfirm: () => {
3725
- removeSelectedOption(optionName);
4382
+ }).filter((action) => action.show !== false),
4383
+ collapse: collapseOptionActions,
4384
+ menu: [
4385
+ !readOnly &&
4386
+ type !== 'code-editor' &&
4387
+ ((options[optionName] as any)?.ui_type || options[optionName]?.type) !==
4388
+ 'code-editor' && {
4389
+ label: 'Focused editing',
4390
+ icon: 'FullscreenLine',
4391
+ className: 'options-item-fullscreen',
4392
+ onClick: () => setFocusedEditing(optionName),
4393
+ },
4394
+ !readOnly &&
4395
+ hasOptionChanged((other as any).value, optionName) && {
4396
+ label: 'Revert changes',
4397
+ icon: 'HistoryLine',
4398
+ className: 'options-item-revert',
4399
+ onClick: () => {
4400
+ handleValueChange(
4401
+ optionName,
4402
+ originalValue.current?.[optionName]?.value,
4403
+ originalValue.current?.[optionName]?.type
4404
+ );
3726
4405
  },
3727
- });
3728
- },
3729
- },
3730
- ],
4406
+ },
4407
+ !readOnly &&
4408
+ (other as any).value &&
4409
+ !isEqual((other as any).value, getDefaultValue(options[optionName])) &&
4410
+ !(options[optionName]?.disabled || (options[optionName] as any)?.readonly) && {
4411
+ label: 'Remove value',
4412
+ icon: 'CloseLine',
4413
+ className: 'options-item-remove',
4414
+ onClick: () => handleValueChange(optionName, undefined),
4415
+ },
4416
+ !readOnly &&
4417
+ !options[optionName]?.preselected &&
4418
+ !options[optionName]?.required && {
4419
+ label: 'Remove option',
4420
+ icon: 'DeleteBinLine',
4421
+ intent: 'danger',
4422
+ className: 'options-optional-remove',
4423
+ onClick: () => {
4424
+ confirmAction({
4425
+ title: 'Remove Selected Option',
4426
+ onConfirm: () => {
4427
+ removeSelectedOption(optionName);
4428
+ },
4429
+ });
4430
+ },
4431
+ },
4432
+ ],
4433
+ }),
3731
4434
  content: (
3732
4435
  <FocusedEditing
3733
4436
  isFullscreen={focusedEditing === optionName}