@qoretechnologies/reqraft 0.10.55-pr.124.g77dd26c → 0.10.55

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