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

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 (291) hide show
  1. package/.claude/CLAUDE.md +25 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/STORY_NETWORK_ISOLATION.md +98 -0
  4. package/README.md +1 -1
  5. package/design/FORM_ENGINE_EXPRESSIONS.md +110 -0
  6. package/design/FORM_ENGINE_FIELD_MIGRATION.md +155 -1
  7. package/design/RENDER_EXPRESSION_TRANSPORT.md +76 -10
  8. package/design/SMART_EDITOR_UX.md +21 -0
  9. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  10. package/dist/components/dpqlEditor/DpqlEditor.js +3 -3
  11. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  12. package/dist/components/dpqlEditor/dpqlHelpers.d.ts +6 -0
  13. package/dist/components/dpqlEditor/dpqlHelpers.d.ts.map +1 -1
  14. package/dist/components/dpqlEditor/dpqlHelpers.js +21 -2
  15. package/dist/components/dpqlEditor/dpqlHelpers.js.map +1 -1
  16. package/dist/components/dpqlEditor/dpqlProbe.d.ts +37 -0
  17. package/dist/components/dpqlEditor/dpqlProbe.d.ts.map +1 -0
  18. package/dist/components/dpqlEditor/dpqlProbe.js +203 -0
  19. package/dist/components/dpqlEditor/dpqlProbe.js.map +1 -0
  20. package/dist/components/dpqlEditor/dpqlTags.d.ts +8 -0
  21. package/dist/components/dpqlEditor/dpqlTags.d.ts.map +1 -1
  22. package/dist/components/dpqlEditor/dpqlTags.js +5 -1
  23. package/dist/components/dpqlEditor/dpqlTags.js.map +1 -1
  24. package/dist/components/dpqlEditor/index.d.ts +4 -0
  25. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  26. package/dist/components/dpqlEditor/index.js +5 -1
  27. package/dist/components/dpqlEditor/index.js.map +1 -1
  28. package/dist/components/dpqlEditor/types.d.ts +42 -2
  29. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  30. package/dist/components/dpqlEditor/types.js +0 -4
  31. package/dist/components/dpqlEditor/types.js.map +1 -1
  32. package/dist/components/dpqlEditor/useDpqlProbe.d.ts +4 -0
  33. package/dist/components/dpqlEditor/useDpqlProbe.d.ts.map +1 -0
  34. package/dist/components/dpqlEditor/useDpqlProbe.js +29 -0
  35. package/dist/components/dpqlEditor/useDpqlProbe.js.map +1 -0
  36. package/dist/components/dpqlEditor/useDpqlSession.d.ts +5 -2
  37. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  38. package/dist/components/dpqlEditor/useDpqlSession.js +8 -2
  39. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  40. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  41. package/dist/components/form/engine/CompactRow.js +192 -45
  42. package/dist/components/form/engine/CompactRow.js.map +1 -1
  43. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  44. package/dist/components/form/engine/CompactToolbar.js +5 -2
  45. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  46. package/dist/components/form/engine/FormEngine.d.ts +160 -12
  47. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  48. package/dist/components/form/engine/FormEngine.js +380 -78
  49. package/dist/components/form/engine/FormEngine.js.map +1 -1
  50. package/dist/components/form/engine/FormFieldsSkeleton.d.ts +48 -0
  51. package/dist/components/form/engine/FormFieldsSkeleton.d.ts.map +1 -0
  52. package/dist/components/form/engine/FormFieldsSkeleton.js +38 -0
  53. package/dist/components/form/engine/FormFieldsSkeleton.js.map +1 -0
  54. package/dist/components/form/engine/OptionFieldMessages.d.ts +17 -2
  55. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  56. package/dist/components/form/engine/OptionFieldMessages.js +73 -12
  57. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  58. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  59. package/dist/components/form/engine/_structuredData/SchemaDataView.js +7 -1
  60. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  61. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  62. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  63. package/dist/components/form/engine/readFirst.d.ts +42 -1
  64. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  65. package/dist/components/form/engine/readFirst.js +74 -7
  66. package/dist/components/form/engine/readFirst.js.map +1 -1
  67. package/dist/components/form/engine/rendererTypes.d.ts +13 -0
  68. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -1
  69. package/dist/components/form/engine/rendererTypes.js.map +1 -1
  70. package/dist/components/form/engine/rowMenuContext.d.ts +78 -0
  71. package/dist/components/form/engine/rowMenuContext.d.ts.map +1 -0
  72. package/dist/components/form/engine/rowMenuContext.js +103 -0
  73. package/dist/components/form/engine/rowMenuContext.js.map +1 -0
  74. package/dist/components/form/engine/typeChoices.d.ts +42 -0
  75. package/dist/components/form/engine/typeChoices.d.ts.map +1 -0
  76. package/dist/components/form/engine/typeChoices.js +31 -0
  77. package/dist/components/form/engine/typeChoices.js.map +1 -0
  78. package/dist/components/form/expressions/DpqlRendering.d.ts +26 -0
  79. package/dist/components/form/expressions/DpqlRendering.d.ts.map +1 -0
  80. package/dist/components/form/expressions/DpqlRendering.js +53 -0
  81. package/dist/components/form/expressions/DpqlRendering.js.map +1 -0
  82. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  83. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  84. package/dist/components/form/expressions/ExpressionField.js +203 -45
  85. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  86. package/dist/components/form/expressions/argumentPresence.d.ts +97 -0
  87. package/dist/components/form/expressions/argumentPresence.d.ts.map +1 -0
  88. package/dist/components/form/expressions/argumentPresence.js +203 -0
  89. package/dist/components/form/expressions/argumentPresence.js.map +1 -0
  90. package/dist/components/form/expressions/builder/argumentLabel.d.ts.map +1 -1
  91. package/dist/components/form/expressions/builder/argumentLabel.js +9 -4
  92. package/dist/components/form/expressions/builder/argumentLabel.js.map +1 -1
  93. package/dist/components/form/expressions/builder/index.d.ts +15 -2
  94. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  95. package/dist/components/form/expressions/builder/index.js +20 -58
  96. package/dist/components/form/expressions/builder/index.js.map +1 -1
  97. package/dist/components/form/expressions/builder/renderTemplate.d.ts +4 -1
  98. package/dist/components/form/expressions/builder/renderTemplate.d.ts.map +1 -1
  99. package/dist/components/form/expressions/builder/renderTemplate.js +10 -67
  100. package/dist/components/form/expressions/builder/renderTemplate.js.map +1 -1
  101. package/dist/components/form/expressions/dpqlMockLanguage.d.ts +76 -0
  102. package/dist/components/form/expressions/dpqlMockLanguage.d.ts.map +1 -0
  103. package/dist/components/form/expressions/dpqlMockLanguage.js +525 -0
  104. package/dist/components/form/expressions/dpqlMockLanguage.js.map +1 -0
  105. package/dist/components/form/expressions/dpqlMockLsp.d.ts +31 -2
  106. package/dist/components/form/expressions/dpqlMockLsp.d.ts.map +1 -1
  107. package/dist/components/form/expressions/dpqlMockLsp.js +86 -80
  108. package/dist/components/form/expressions/dpqlMockLsp.js.map +1 -1
  109. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  110. package/dist/components/form/expressions/mockExpressions.js +8 -4
  111. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  112. package/dist/components/form/expressions/renderExpressionToText.d.ts +1 -1
  113. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  114. package/dist/components/form/expressions/renderExpressionToText.js +95 -19
  115. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  116. package/dist/components/form/expressions/types.d.ts +7 -0
  117. package/dist/components/form/expressions/types.d.ts.map +1 -1
  118. package/dist/components/form/expressions/useRenderExpression.d.ts +7 -16
  119. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  120. package/dist/components/form/expressions/useRenderExpression.js +50 -126
  121. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  122. package/dist/components/form/fields/Field.d.ts.map +1 -1
  123. package/dist/components/form/fields/Field.js +3 -1
  124. package/dist/components/form/fields/Field.js.map +1 -1
  125. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +11 -1
  126. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  127. package/dist/components/form/fields/allowed-values/AllowedValues.js +14 -2
  128. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  129. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  130. package/dist/components/form/fields/auto/AutoFormField.js +91 -28
  131. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  132. package/dist/components/form/fields/date/Date.d.ts +1 -1
  133. package/dist/components/form/fields/date/Date.d.ts.map +1 -1
  134. package/dist/components/form/fields/date/Date.js +16 -2
  135. package/dist/components/form/fields/date/Date.js.map +1 -1
  136. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  137. package/dist/components/form/fields/long-string/LongString.js +14 -2
  138. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  139. package/dist/components/form/fields/rich-text/RichText.d.ts +35 -2
  140. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  141. package/dist/components/form/fields/rich-text/RichText.js +153 -12
  142. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  143. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +2 -1
  144. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  145. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -2
  146. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  147. package/dist/components/form/fields/template/TemplateField.d.ts +34 -2
  148. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  149. package/dist/components/form/fields/template/TemplateField.js +604 -58
  150. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  151. package/dist/components/form/index.d.ts +1 -0
  152. package/dist/components/form/index.d.ts.map +1 -1
  153. package/dist/components/form/index.js +1 -0
  154. package/dist/components/form/index.js.map +1 -1
  155. package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
  156. package/dist/components/smartEditor/SmartEditor.js +29 -8
  157. package/dist/components/smartEditor/SmartEditor.js.map +1 -1
  158. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts +8 -0
  159. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts.map +1 -1
  160. package/dist/components/smartEditor/__fixtures__/mockLspServer.js +53 -10
  161. package/dist/components/smartEditor/__fixtures__/mockLspServer.js.map +1 -1
  162. package/dist/components/smartEditor/useLspSession.d.ts +5 -1
  163. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  164. package/dist/components/smartEditor/useLspSession.js +16 -2
  165. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  166. package/dist/helpers/dpqlDetection.d.ts +58 -0
  167. package/dist/helpers/dpqlDetection.d.ts.map +1 -0
  168. package/dist/helpers/dpqlDetection.js +164 -0
  169. package/dist/helpers/dpqlDetection.js.map +1 -0
  170. package/dist/helpers/optionUiTypes.d.ts +19 -3
  171. package/dist/helpers/optionUiTypes.d.ts.map +1 -1
  172. package/dist/helpers/optionUiTypes.js +20 -1
  173. package/dist/helpers/optionUiTypes.js.map +1 -1
  174. package/dist/helpers/options.d.ts +37 -0
  175. package/dist/helpers/options.d.ts.map +1 -1
  176. package/dist/helpers/options.js +78 -1
  177. package/dist/helpers/options.js.map +1 -1
  178. package/dist/helpers/templateItems.d.ts +47 -0
  179. package/dist/helpers/templateItems.d.ts.map +1 -0
  180. package/dist/helpers/templateItems.js +88 -0
  181. package/dist/helpers/templateItems.js.map +1 -0
  182. package/dist/helpers/templateText.d.ts +37 -0
  183. package/dist/helpers/templateText.d.ts.map +1 -0
  184. package/dist/helpers/templateText.js +64 -0
  185. package/dist/helpers/templateText.js.map +1 -0
  186. package/dist/helpers/templates.d.ts +19 -11
  187. package/dist/helpers/templates.d.ts.map +1 -1
  188. package/dist/helpers/templates.js +46 -29
  189. package/dist/helpers/templates.js.map +1 -1
  190. package/dist/helpers/validations.d.ts.map +1 -1
  191. package/dist/helpers/validations.js +56 -9
  192. package/dist/helpers/validations.js.map +1 -1
  193. package/dist/hooks/useArgSchema.d.ts +2 -1
  194. package/dist/hooks/useArgSchema.d.ts.map +1 -1
  195. package/dist/hooks/useFetch/useFetch.d.ts +3 -1
  196. package/dist/hooks/useFetch/useFetch.d.ts.map +1 -1
  197. package/dist/hooks/useFetch/useFetch.js +47 -9
  198. package/dist/hooks/useFetch/useFetch.js.map +1 -1
  199. package/dist/hooks/useTemplates.d.ts +20 -0
  200. package/dist/hooks/useTemplates.d.ts.map +1 -1
  201. package/dist/hooks/useTemplates.js +34 -2
  202. package/dist/hooks/useTemplates.js.map +1 -1
  203. package/dist/index.d.ts +2 -1
  204. package/dist/index.d.ts.map +1 -1
  205. package/dist/index.js +5 -1
  206. package/dist/index.js.map +1 -1
  207. package/dist/stories/storyNetwork.d.ts +36 -0
  208. package/dist/stories/storyNetwork.d.ts.map +1 -0
  209. package/dist/stories/storyNetwork.js +82 -0
  210. package/dist/stories/storyNetwork.js.map +1 -0
  211. package/dist/utils/fetch.d.ts +151 -1
  212. package/dist/utils/fetch.d.ts.map +1 -1
  213. package/dist/utils/fetch.js +256 -44
  214. package/dist/utils/fetch.js.map +1 -1
  215. package/dist/utils/lspClient.d.ts +6 -0
  216. package/dist/utils/lspClient.d.ts.map +1 -1
  217. package/dist/utils/lspClient.js +62 -11
  218. package/dist/utils/lspClient.js.map +1 -1
  219. package/dist/utils/websocket.d.ts +6 -0
  220. package/dist/utils/websocket.d.ts.map +1 -1
  221. package/dist/utils/websocket.js +15 -7
  222. package/dist/utils/websocket.js.map +1 -1
  223. package/package.json +2 -2
  224. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +3 -119
  225. package/src/components/dpqlEditor/DpqlEditor.tsx +5 -2
  226. package/src/components/dpqlEditor/dpqlHelpers.ts +20 -1
  227. package/src/components/dpqlEditor/dpqlProbe.ts +165 -0
  228. package/src/components/dpqlEditor/dpqlTags.ts +13 -0
  229. package/src/components/dpqlEditor/index.ts +4 -0
  230. package/src/components/dpqlEditor/types.ts +38 -2
  231. package/src/components/dpqlEditor/useDpqlProbe.ts +33 -0
  232. package/src/components/dpqlEditor/useDpqlSession.ts +20 -4
  233. package/src/components/form/engine/CompactRow.tsx +240 -51
  234. package/src/components/form/engine/CompactToolbar.tsx +10 -3
  235. package/src/components/form/engine/FormEngine.stories.tsx +431 -124
  236. package/src/components/form/engine/FormEngine.tsx +508 -90
  237. package/src/components/form/engine/FormFieldsSkeleton.tsx +76 -0
  238. package/src/components/form/engine/OptionFieldMessages.tsx +103 -12
  239. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +11 -0
  240. package/src/components/form/engine/compactToolbarContext.ts +1 -1
  241. package/src/components/form/engine/readFirst.ts +86 -7
  242. package/src/components/form/engine/rendererTypes.ts +12 -0
  243. package/src/components/form/engine/rowMenuContext.ts +159 -0
  244. package/src/components/form/engine/typeChoices.ts +61 -0
  245. package/src/components/form/expressions/DpqlRendering.tsx +85 -0
  246. package/src/components/form/expressions/ExpressionField.stories.tsx +826 -48
  247. package/src/components/form/expressions/ExpressionField.tsx +266 -54
  248. package/src/components/form/expressions/argumentPresence.ts +200 -0
  249. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +31 -14
  250. package/src/components/form/expressions/builder/argumentLabel.tsx +10 -4
  251. package/src/components/form/expressions/builder/index.tsx +39 -77
  252. package/src/components/form/expressions/builder/renderTemplate.tsx +23 -56
  253. package/src/components/form/expressions/dpqlMockLanguage.ts +565 -0
  254. package/src/components/form/expressions/dpqlMockLsp.ts +109 -83
  255. package/src/components/form/expressions/mockExpressions.ts +8 -4
  256. package/src/components/form/expressions/renderExpressionToText.ts +112 -22
  257. package/src/components/form/expressions/types.ts +7 -0
  258. package/src/components/form/expressions/useRenderExpression.ts +43 -127
  259. package/src/components/form/fields/Field.tsx +3 -2
  260. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -2
  261. package/src/components/form/fields/auto/AutoFormField.stories.tsx +85 -18
  262. package/src/components/form/fields/auto/AutoFormField.tsx +90 -20
  263. package/src/components/form/fields/date/Date.tsx +17 -1
  264. package/src/components/form/fields/long-string/LongString.tsx +15 -3
  265. package/src/components/form/fields/rich-text/RichText.tsx +235 -30
  266. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -2
  267. package/src/components/form/fields/template/TemplateField.stories.tsx +93 -14
  268. package/src/components/form/fields/template/TemplateField.tsx +823 -129
  269. package/src/components/form/index.tsx +1 -0
  270. package/src/components/log/Log.stories.tsx +2 -1
  271. package/src/components/smartEditor/SmartEditor.tsx +41 -9
  272. package/src/components/smartEditor/__fixtures__/mockLspServer.ts +52 -3
  273. package/src/components/smartEditor/useLspSession.ts +21 -3
  274. package/src/helpers/dpqlDetection.ts +209 -0
  275. package/src/helpers/optionUiTypes.ts +22 -0
  276. package/src/helpers/options.ts +70 -0
  277. package/src/helpers/templateItems.ts +81 -0
  278. package/src/helpers/templateText.ts +92 -0
  279. package/src/helpers/templates.ts +47 -37
  280. package/src/helpers/validations.ts +59 -9
  281. package/src/hooks/useFetch/useFetch.stories.tsx +6 -5
  282. package/src/hooks/useFetch/useFetch.tsx +53 -7
  283. package/src/hooks/useStorage/useStorage.stories.tsx +2 -1
  284. package/src/hooks/useTemplates.ts +41 -0
  285. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +2 -1
  286. package/src/index.tsx +14 -1
  287. package/src/stores/currentUser/currentUser.stories.tsx +2 -1
  288. package/src/stories/storyNetwork.ts +92 -0
  289. package/src/utils/fetch.ts +311 -21
  290. package/src/utils/lspClient.ts +68 -11
  291. package/src/utils/websocket.ts +20 -9
@@ -24,6 +24,7 @@ export * from './fields/string/String';
24
24
  export * from './fields/template/ExampleValueModal';
25
25
  export * from './fields/template/TemplateField';
26
26
  export * from './engine/FormEngine';
27
+ export * from './engine/FormFieldsSkeleton';
27
28
  export * from './engine/optionActions';
28
29
  export * from './engine/rendererTypes';
29
30
  export * from './expressions/types';
@@ -2,6 +2,7 @@ import { StoryObj } from '@storybook/react-vite';
2
2
  import { expect, fireEvent, fn } from 'storybook/test';
3
3
  import { Server } from 'mock-socket';
4
4
  import { sleep, testsClickButton, testsWaitForText } from '../../../__tests__/utils';
5
+ import { storySocketUrl } from '../../stories/storyNetwork';
5
6
  import { StoryMeta } from '../../types';
6
7
  import { ReqraftLog } from './Log';
7
8
 
@@ -24,7 +25,7 @@ const meta = {
24
25
  mockdate: new Date('2024-01-01T08:00:00.000Z'),
25
26
  },
26
27
  async beforeEach() {
27
- const url = `wss://hq.qoretechnologies.com:8092/log-test?token=${process.env.REACT_APP_QORUS_TOKEN}`;
28
+ const url = storySocketUrl('log-test');
28
29
  let server = new Server(url);
29
30
  let killTimeout: NodeJS.Timeout;
30
31
  let fakeInterval: NodeJS.Timeout;
@@ -18,7 +18,16 @@ import {
18
18
  } from '@qoretechnologies/reqore/dist/components/RichTextEditor';
19
19
  import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors';
20
20
  import { IReqoreTooltip } from '@qoretechnologies/reqore/dist/types/global';
21
- import React, { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
21
+ import React, {
22
+ forwardRef,
23
+ memo,
24
+ useCallback,
25
+ useEffect,
26
+ useImperativeHandle,
27
+ useMemo,
28
+ useReducer,
29
+ useRef,
30
+ } from 'react';
22
31
  import { Editor, NodeEntry, Range, Transforms } from 'slate';
23
32
  import { ReactEditor, RenderLeafProps } from 'slate-react';
24
33
  import { ILspSignatureHelp } from '../../utils/lspClient.types';
@@ -320,6 +329,14 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
320
329
  // ReqoreRichTextEditor's tokenizer mid-token (typed `@s` collapses to a
321
330
  // tag chip, making the rest of the field name impossible to type).
322
331
  const slateValueRef = useRef<ISlateElement[] | null>(null);
332
+ /* Counts the node trees the editor has reported. The tree a value converts
333
+ to is not always the tree the editor holds: Slate normalises a value set
334
+ from outside — a leading chip gains an empty text leaf before it — without
335
+ changing its text, so `value` never changes and the cache above kept the
336
+ tree the editor no longer had. Every offset computed from it (token
337
+ colours, diagnostics, signature help) then pointed one leaf off: a Text
338
+ view seeded with `"$local:name" == "John"` was never highlighted. */
339
+ const [editorTreeVersion, noteEditorTree] = useReducer((count: number) => count + 1, 0);
323
340
 
324
341
  useImperativeHandle(ref, () => editorRef.current as TReqoreRichTextEditorRef, []);
325
342
 
@@ -350,7 +367,17 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
350
367
  const next = converter.toSlateNodes(value);
351
368
  slateValueRef.current = next;
352
369
  return next;
353
- }, [value, converter]);
370
+ // `editorTreeVersion` re-reads the cache once the editor reports its own tree.
371
+ }, [value, converter, editorTreeVersion]);
372
+
373
+ /* A value set from outside is a change to the document as much as typing
374
+ is — a Text view seeded after mount, a Preview following the author. The
375
+ server colours and diagnoses its own copy of the text, so it must hold
376
+ this one; typing already sent its text, and is not resent. */
377
+ const { didChange: syncDocument } = session;
378
+ useEffect(() => {
379
+ syncDocument(value);
380
+ }, [syncDocument, value]);
354
381
 
355
382
  const diagnosticDecorate = useLspDiagnosticDecorations(
356
383
  session.diagnostics,
@@ -450,15 +477,20 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
450
477
  (newNodes: ISlateElement[]) => {
451
478
  const plainText = converter.fromSlateNodes(newNodes);
452
479
 
480
+ // A selection change reports the same tree; anything else is the tree
481
+ // the editor now holds, and the one every offset is computed against.
482
+ if (newNodes !== slateValueRef.current) {
483
+ slateValueRef.current = newNodes;
484
+ noteEditorTree();
485
+ }
486
+
453
487
  if (plainText !== lastPlainTextRef.current) {
454
488
  lastPlainTextRef.current = plainText;
455
- // CRITICAL: refresh `slateValueRef` to the live nodes too. The
456
- // next render's `value` equals `lastPlainTextRef` (the typing
457
- // echo), so `slateValue`'s cache returns this ref — leave it
458
- // stale and downstream hooks (signature help, semantic tokens)
459
- // never recompute, so the signature pill's active parameter
460
- // never advances.
461
- slateValueRef.current = newNodes;
489
+ // CRITICAL: the next render's `value` equals `lastPlainTextRef` (the
490
+ // typing echo), so `slateValue`'s cache returns the live nodes set
491
+ // above — leave them stale and downstream hooks (signature help,
492
+ // semantic tokens) never recompute, so the signature pill's active
493
+ // parameter never advances.
462
494
  onChange(plainText);
463
495
  session.didChange(plainText);
464
496
  }
@@ -2,8 +2,10 @@
2
2
  // (heartbeat, initialize, document tracking, request dispatch); story files
3
3
  // register only their language-specific handlers.
4
4
  import { Client, Server } from 'mock-socket';
5
+ import { storySocketUrl } from '../../../stories/storyNetwork';
5
6
 
6
- export const MOCK_LSP_URL = `wss://hq.qoretechnologies.com:8092/lsp?token=${process.env.REACT_APP_QORUS_TOKEN}`;
7
+ /** Where `ReqraftLspClient` dials by default, on the instance stories use. */
8
+ export const MOCK_LSP_URL = storySocketUrl('lsp');
7
9
 
8
10
  // LSP-standard 16-type / 6-modifier legend, as the real Qorus server
9
11
  // advertises in `initialize` (qorus/Classes/QorusLspWebSocketHandler.qc:847).
@@ -76,31 +78,69 @@ export interface IMockLspServer {
76
78
  received: IMockLspMessage[];
77
79
  connectionCount: number;
78
80
  getOpenConnectionCount: () => number;
79
- // Most-recent document text from didOpen / didChange.
81
+ // Most-recent document text from didOpen / didChange, whichever document.
80
82
  documentText: string;
83
+ /**
84
+ * The text of every open document, by URI. Clients share ONE socket per
85
+ * endpoint and open a document each — an editor and several read-only
86
+ * renderings at once — so a per-document answer (semantic tokens) must read
87
+ * its own document, not whichever one changed last.
88
+ */
89
+ textOf: (uri?: string) => string;
81
90
  delays: Record<string, number>;
82
91
  notify: (method: string, params: unknown) => void;
83
92
  close: () => void;
84
93
  }
85
94
 
95
+ /* One mock per URL at a time. `.storybook/preview.tsx` starts a default DPQL
96
+ language server for every story, and a story that needs its own handlers
97
+ creates a server on the same URL; mock-socket refuses a second server there,
98
+ so creating one replaces whichever is listening. */
99
+ const activeServers = new Map<string, IMockLspServer>();
100
+
86
101
  export const createMockLspServer = (
87
102
  url: string,
88
103
  options: IMockLspServerOptions = {}
89
104
  ): IMockLspServer => {
105
+ // No connection reset here: `.storybook/preview.tsx` resets the shared LSP
106
+ // connections (and the expression render client) before every story, which
107
+ // covers stories that never create a mock server as well as those that do.
108
+ activeServers.get(url)?.close();
90
109
  const server = new Server(url);
91
110
  const sockets: Client[] = [];
111
+ const documents = new Map<string, string>();
112
+ let closed = false;
92
113
  const lsp: IMockLspServer = {
93
114
  received: [],
94
115
  connectionCount: 0,
95
116
  getOpenConnectionCount: () => sockets.filter((socket) => socket.readyState === 1).length,
96
117
  documentText: '',
118
+ textOf: (uri) => (uri !== undefined && documents.has(uri) ? documents.get(uri)! : lsp.documentText),
97
119
  delays: { ...options.delays },
98
120
  notify: (method, params) =>
99
121
  sockets.forEach((socket) =>
100
122
  socket.send(JSON.stringify({ jsonrpc: '2.0', method, params }))
101
123
  ),
102
- close: () => server.close(),
124
+ close: () => {
125
+ /* Once only. mock-socket finds clients by URL, not by server, so closing a
126
+ server that was already replaced would close its REPLACEMENT's clients
127
+ — and the default server is closed twice when a story replaces it: once
128
+ on replacement, once in the preview's teardown. */
129
+ if (closed) {
130
+ return;
131
+ }
132
+ closed = true;
133
+ if (activeServers.get(url) === lsp) {
134
+ activeServers.delete(url);
135
+ }
136
+ server.close();
137
+ // `close()` drops the clients but leaves mock-socket's WebSocket installed
138
+ // on the page; only `stop()` puts the real one back. Without it a story
139
+ // that reaches a real server (`parameters.live`) dials the mock instead.
140
+ server.stop();
141
+ },
103
142
  };
143
+ activeServers.set(url, lsp);
104
144
 
105
145
  server.on('connection', (socket) => {
106
146
  lsp.connectionCount++;
@@ -119,14 +159,23 @@ export const createMockLspServer = (
119
159
  }
120
160
  lsp.received.push(msg);
121
161
 
162
+ const uri = msg.params?.textDocument?.uri;
122
163
  if (msg.method === 'textDocument/didOpen') {
123
164
  lsp.documentText = msg.params?.textDocument?.text ?? '';
165
+ if (uri !== undefined) {
166
+ documents.set(uri, lsp.documentText);
167
+ }
124
168
  } else if (msg.method === 'textDocument/didChange') {
125
169
  // The client sends a single full-document content change.
126
170
  const change = msg.params?.contentChanges?.[0];
127
171
  if (change && typeof change.text === 'string') {
128
172
  lsp.documentText = change.text;
173
+ if (uri !== undefined) {
174
+ documents.set(uri, change.text);
175
+ }
129
176
  }
177
+ } else if (msg.method === 'textDocument/didClose' && uri !== undefined) {
178
+ documents.delete(uri);
130
179
  }
131
180
 
132
181
  const handler = options.handlers?.[msg.method];
@@ -77,7 +77,11 @@ export interface IUseLspSessionResult {
77
77
  * only fires when `capabilities?.signatureHelpProvider` exists).
78
78
  */
79
79
  capabilities: ILspServerCapabilities | null;
80
- /** Forward content changes to the server via `didChange`. */
80
+ /**
81
+ * The editor now holds `text`: the server is sent it via `didChange`, or via
82
+ * `didOpen` if the document is not open yet. Text the server already holds
83
+ * is not resent, so it is safe to call for every value the editor shows.
84
+ */
81
85
  didChange: (text: string) => void;
82
86
  /**
83
87
  * Request `textDocument/completion` at a plain-text offset. Converts
@@ -96,6 +100,13 @@ export function useLspSession(
96
100
 
97
101
  const clientRef = useRef<ReqraftLspClient | null>(null);
98
102
  const versionRef = useRef(1);
103
+ /* The text the editor holds, and the text the server holds for this
104
+ document — `null` until the document is open. The server tokenises and
105
+ diagnoses ITS copy, so every text the editor shows has to reach it, however
106
+ it got there: typed, or set from outside after mount (a Text view seeded
107
+ once `dpql/serialize` answers, a Preview following the typing). */
108
+ const editorTextRef = useRef(initialText);
109
+ const serverTextRef = useRef<string | null>(null);
99
110
  const uriRef = useRef(uriProp ?? `${languageId}://session/${++lspUriCounter}`);
100
111
 
101
112
  const [client, setClient] = useState<ReqraftLspClient | null>(null);
@@ -129,8 +140,11 @@ export function useLspSession(
129
140
  // result.
130
141
  setSemanticTokensLegend(c.semanticTokensLegend);
131
142
  setCapabilities(c.capabilities);
143
+ // Opened with the text the editor holds NOW, which may have been set
144
+ // while the connection was opening.
145
+ serverTextRef.current = editorTextRef.current;
132
146
  c.didOpen(
133
- initialText,
147
+ editorTextRef.current,
134
148
  initialMetadata && Object.keys(initialMetadata).length > 0
135
149
  ? initialMetadata
136
150
  : undefined
@@ -146,6 +160,7 @@ export function useLspSession(
146
160
  return () => {
147
161
  c.disconnect();
148
162
  clientRef.current = null;
163
+ serverTextRef.current = null;
149
164
  setClient(null);
150
165
  setIsReady(false);
151
166
  setSemanticTokensLegend(null);
@@ -158,8 +173,11 @@ export function useLspSession(
158
173
  }, []);
159
174
 
160
175
  const didChange = useCallback((text: string) => {
176
+ editorTextRef.current = text;
161
177
  const c = clientRef.current;
162
- if (!c) return;
178
+ // Not open yet: `didOpen` will carry it. Already held: nothing to send.
179
+ if (!c || serverTextRef.current === null || serverTextRef.current === text) return;
180
+ serverTextRef.current = text;
163
181
  c.didChange(text, ++versionRef.current);
164
182
  }, []);
165
183
 
@@ -0,0 +1,209 @@
1
+ // Copyright 2026 Qore Technologies, s.r.o.
2
+ /**
3
+ * Noticing that text typed into a PLAIN (non-expression) field is really a
4
+ * DPQL expression, and deciding what to do about it.
5
+ *
6
+ * The rule this implements, in one sentence: **switch the field into
7
+ * expression mode only when the text cannot be a valid literal for the
8
+ * field's type but IS a real expression; otherwise offer, never silently
9
+ * reinterpret.** Quietly turning the string `a + b` into a concatenation on a
10
+ * `string` field would destroy a legitimate literal, so a type that accepts
11
+ * any text never auto-switches — it only ever offers.
12
+ *
13
+ * ## Why the server has the last word
14
+ *
15
+ * "Does this parse as DPQL?" is NOT a usable test, which is the trap here.
16
+ * Verified against the live LSP: `dpql/parse` reports `success: true` for
17
+ * `hello`, for `42`, for `2026-09-06` and for `@a` — it wraps each of them in
18
+ * a trivial `{exp: 'value', args: [...]}` node. Everything is a valid DPQL
19
+ * program, because a bare literal is one. So a text that parses tells us
20
+ * nothing; what tells us something is the SHAPE of the AST that comes back:
21
+ *
22
+ * | typed text | `exp` | verdict |
23
+ * |-------------------|------------|------------|
24
+ * | `hello`, `42` | `value` | a literal |
25
+ * | `$local:count` | `template` | a template |
26
+ * | `@a > 5` | `>` | expression |
27
+ * | `"a" + "b"` | `+` | expression |
28
+ * | `toInt("5")` | `toInt` | expression |
29
+ *
30
+ * `isRealExpressionAst` is that test, and it is the only authority. The
31
+ * lexical pass below decides nothing — its sole job is to keep a server
32
+ * round-trip off every keystroke of every field.
33
+ */
34
+ import { validateField } from './validations';
35
+
36
+ /** What to do about text that turned out to be an expression. */
37
+ export type TDpqlDetectionOutcome =
38
+ /** Not an expression, or not one we may act on — leave the field alone. */
39
+ | 'none'
40
+ /** A real expression, but the text is also a valid literal here: ask. */
41
+ | 'offer'
42
+ /** A real expression, and the text cannot be a literal here: switch. */
43
+ | 'switch';
44
+
45
+ /**
46
+ * The `exp` values that mean "this is not an expression": a bare literal and
47
+ * a bare template reference. A template is already the template field's job
48
+ * (`isValueTemplate`), and a literal is what the plain field is for.
49
+ */
50
+ const NON_EXPRESSION_OPS = new Set(['value', 'template']);
51
+
52
+ /**
53
+ * Operators and calls whose presence makes a server probe worth the trip.
54
+ * Deliberately generous — a false positive costs one debounced request that
55
+ * comes back `exp: 'value'`, while a false negative silently loses the whole
56
+ * feature.
57
+ *
58
+ * ## Why the spacing rules differ per operator
59
+ *
60
+ * `-` and `/` are ambiguous: they appear inside values nobody means as
61
+ * arithmetic — dates (`2026-09-06`), negative numbers (`-5`), paths (`a/b`),
62
+ * hyphenated names (`well-known-name`). A space next to one is what separates
63
+ * `@a - 1` from those, so they keep a spacing requirement.
64
+ *
65
+ * `+` is NOT ambiguous in that way, and requiring spaces around it lost the
66
+ * feature for the most natural way to type arithmetic: `2 + 1` was offered as
67
+ * an expression while `2+1` and `2+ 1` were not, which reads as the detection
68
+ * working only sometimes. Reported from the live IDE. `+` now counts wherever
69
+ * it appears; if the text turns out to be an ordinary literal the server says
70
+ * so and the offer never appears.
71
+ *
72
+ * The `-`/`/` rule is also relaxed from "spaces on BOTH sides" to "a space on
73
+ * either side", so `@a -1` and `@a- 1` are probed too while every ambiguous
74
+ * value above still is not.
75
+ */
76
+ const DPQL_OPERATOR_PATTERN = new RegExp(
77
+ [
78
+ '(==|!=|<>|>=|<=)', // two-character comparisons
79
+ '[<>*%]', // unambiguous single-character operators
80
+ '(?<!=)=(?!=)', // a lone `=`, not part of `==`
81
+ '\\+', // `+` — unambiguous, so spacing is irrelevant
82
+ '(\\s[-/]|[-/]\\s)', // `-` and `/` need a space on at least one side
83
+ '\\b(and|or|not|like|in|contains)\\b', // word operators
84
+ ].join('|'),
85
+ 'i'
86
+ );
87
+
88
+ /** A function call — `toInt("5")`, `upper(@name)`. */
89
+ const DPQL_CALL_PATTERN = /\b[a-z_]\w*\s*\(/i;
90
+
91
+ /**
92
+ * The longest text worth probing. An expression a person types into a form
93
+ * field is short; anything longer is a document, and probing it on every
94
+ * keystroke would be a waste of a round-trip.
95
+ */
96
+ const MAX_PROBE_LENGTH = 512;
97
+
98
+ /**
99
+ * Cheap, purely lexical "might this be worth asking the server about?".
100
+ * Never decides anything on its own — see the module comment.
101
+ */
102
+ export const mightBeDpqlExpression = (text: unknown): boolean => {
103
+ if (typeof text !== 'string') {
104
+ return false;
105
+ }
106
+
107
+ const trimmed = text.trim();
108
+
109
+ if (!trimmed || trimmed.length > MAX_PROBE_LENGTH) {
110
+ return false;
111
+ }
112
+
113
+ return DPQL_OPERATOR_PATTERN.test(trimmed) || DPQL_CALL_PATTERN.test(trimmed);
114
+ };
115
+
116
+ /**
117
+ * The authority: does this `dpql/parse` result describe a REAL expression
118
+ * rather than a literal or a bare template reference? Accepts either the
119
+ * field-ready envelope (`{is_expression, value: {exp, args}}`) the server
120
+ * returns or a bare `{exp, args}` node.
121
+ */
122
+ export const isRealExpressionAst = (expression: unknown): boolean => {
123
+ if (!expression || typeof expression !== 'object') {
124
+ return false;
125
+ }
126
+
127
+ const envelope = expression as { value?: unknown; exp?: unknown };
128
+ const node = (
129
+ envelope.exp === undefined && envelope.value && typeof envelope.value === 'object'
130
+ ? envelope.value
131
+ : envelope
132
+ ) as { exp?: unknown };
133
+
134
+ return typeof node.exp === 'string' && !!node.exp && !NON_EXPRESSION_OPS.has(node.exp);
135
+ };
136
+
137
+ /**
138
+ * `true` when any diagnostic is an outright error. The server reports
139
+ * severity as the string `'error'` on this route and LSP proper uses `1`;
140
+ * both are accepted rather than assuming one.
141
+ */
142
+ export const hasErrorDiagnostic = (
143
+ diagnostics?: Array<{ severity?: number | string } | undefined | null>
144
+ ): boolean =>
145
+ !!diagnostics?.some((diagnostic) => {
146
+ const severity = diagnostic?.severity;
147
+ return severity === 1 || severity === 'error';
148
+ });
149
+
150
+ /**
151
+ * Could this text stand as a literal value of the field's own type? A type
152
+ * that accepts any text (`string`, and every type the form validator passes
153
+ * by default) always can, which is exactly why such a field never
154
+ * auto-switches. Delegates to the form's own validator so the answer is the
155
+ * same one the field itself would give.
156
+ */
157
+ export const canBeLiteralOfType = (
158
+ text: string,
159
+ type?: string,
160
+ field?: Record<string, any>
161
+ ): boolean => {
162
+ if (!type) {
163
+ return true;
164
+ }
165
+
166
+ try {
167
+ return validateField(type, text, field as any);
168
+ } catch {
169
+ // A validator that throws must not be read as "this cannot be a
170
+ // literal" — that would auto-switch the field on the strength of a bug.
171
+ return true;
172
+ }
173
+ };
174
+
175
+ export interface IClassifyTypedTextArgs {
176
+ /** The text the author typed into the plain field. */
177
+ text: string;
178
+ /** The field's declared type (`ui_type ?? type`). */
179
+ type?: string;
180
+ /** The field schema, for validators that consult `validation_regex` etc. */
181
+ field?: Record<string, any>;
182
+ /** The `dpql/parse` result for `text`, or `undefined` if it has not run. */
183
+ parsed?: {
184
+ success?: boolean;
185
+ expression?: unknown;
186
+ diagnostics?: Array<{ severity?: number | string }>;
187
+ };
188
+ }
189
+
190
+ /**
191
+ * The whole decision, in one pure function so it can be tested without a
192
+ * server, a socket or a rendered field.
193
+ */
194
+ export const classifyTypedText = ({
195
+ text,
196
+ type,
197
+ field,
198
+ parsed,
199
+ }: IClassifyTypedTextArgs): TDpqlDetectionOutcome => {
200
+ if (!parsed?.success || !parsed.expression) {
201
+ return 'none';
202
+ }
203
+
204
+ if (!isRealExpressionAst(parsed.expression) || hasErrorDiagnostic(parsed.diagnostics)) {
205
+ return 'none';
206
+ }
207
+
208
+ return canBeLiteralOfType(text, type, field) ? 'offer' : 'switch';
209
+ };
@@ -159,3 +159,25 @@ export const isOptionInterfaceUiType = (type: unknown): type is TOptionInterface
159
159
 
160
160
  export const isKnownQorusUiType = (type: unknown): type is TKnownQorusUiType =>
161
161
  typeof type === 'string' && knownQorusUiTypeSet.has(type);
162
+
163
+ /** The spellings that mean "no concrete type" wherever they appear. */
164
+ export const UNTYPED_OPTION_TYPES = ['any', 'auto'] as const;
165
+
166
+ export type TUntypedOptionType = (typeof UNTYPED_OPTION_TYPES)[number];
167
+
168
+ const untypedOptionTypeSet = new Set<string>(UNTYPED_OPTION_TYPES);
169
+
170
+ /**
171
+ * `auto` and `any` declare no type: they say the AUTHOR chooses one, and the
172
+ * chosen type is recorded beside the value, not in the schema. A reader that
173
+ * takes the schema's word therefore learns nothing about what the value has to
174
+ * look like — `validateField` handles these two by auto-detecting from the
175
+ * value, which accepts whatever is there.
176
+ *
177
+ * This list lives here, with the rest of the type vocabulary, rather than in
178
+ * `engine/typeChoices` where it started: a helper cannot import from a
179
+ * component, and the rule was already spelled out by hand in seven other
180
+ * places.
181
+ */
182
+ export const isUntypedOptionType = (type: unknown): type is TUntypedOptionType =>
183
+ typeof type === 'string' && untypedOptionTypeSet.has(type);
@@ -52,3 +52,73 @@ export const getListElementValue = (element: unknown): unknown =>
52
52
  element && typeof element === 'object' && !Array.isArray(element) && 'value' in element ?
53
53
  (element as { value: unknown }).value
54
54
  : element;
55
+
56
+ /**
57
+ * The schema with every one-of group that offers no choice turned back into a
58
+ * plain required field.
59
+ *
60
+ * A `required_groups` group says *"at least one of these"*, and the whole
61
+ * affordance built on it — the ONE OF header, the `Covers` / `Covered by “X”`
62
+ * chips, the *"this field or X is required"* message — exists to tell an author
63
+ * that they have a CHOICE and which way they have answered it. When the group
64
+ * has a single member in the schema in hand, there is no choice: that field
65
+ * must be set, which is exactly what `required` already means. Saying it in the
66
+ * one-of grammar instead is worse than saying nothing, because a green tick
67
+ * beside a value reads as confirmation of the VALUE — a Qorus test author read
68
+ * `✓ COVERS` beside their service as "yes, this is the service your test is
69
+ * about", which the form never said and cannot know.
70
+ *
71
+ * This is not a hypothetical: a server may legitimately declare a group over an
72
+ * API-only alternative it then hides from the form (Qorus test steps put
73
+ * `target` in a group with `service`, and hide `target`), so the form is handed
74
+ * one member of a two-member group. The group is still real for the API and
75
+ * must stay in the schema the server serves; it is only this form that has
76
+ * nothing to offer.
77
+ *
78
+ * The rewrite is exact rather than cosmetic: a one-of over one option and a
79
+ * required option are the same constraint, so validation, the completion meter
80
+ * and the required-only filter all keep saying what they said — in the grammar
81
+ * that fits.
82
+ *
83
+ * A field in a degenerate group is required by that group ALONE, so it is
84
+ * marked required even when it also belongs to a group that does offer a
85
+ * choice; that other group keeps its affordance and is satisfied by this field
86
+ * either way. The key is deleted rather than emptied because `[]` is truthy,
87
+ * and every consumer tests `option.required_groups` for existence.
88
+ *
89
+ * Returns the schema unchanged — the same object — when no group degenerates,
90
+ * so it can sit on a memo without giving every render a new schema identity.
91
+ */
92
+ export const resolveDegenerateRequiredGroups = <T extends IQorusFormSchema | undefined>(
93
+ schema: T
94
+ ): T => {
95
+ if (!schema) return schema;
96
+
97
+ const membersPerGroup: Record<string, number> = {};
98
+ Object.values(schema).forEach((option) => {
99
+ (option as TQorusFormFieldSchema)?.required_groups?.forEach((group) => {
100
+ membersPerGroup[group] = (membersPerGroup[group] ?? 0) + 1;
101
+ });
102
+ });
103
+
104
+ const degenerate = (group: string): boolean => membersPerGroup[group] < 2;
105
+ if (!Object.keys(membersPerGroup).some(degenerate)) return schema;
106
+
107
+ const resolved: IQorusFormSchema = {};
108
+ Object.entries(schema).forEach(([name, option]) => {
109
+ const groups = (option as TQorusFormFieldSchema)?.required_groups;
110
+ if (!groups?.some(degenerate)) {
111
+ resolved[name] = option;
112
+ return;
113
+ }
114
+ const kept = groups.filter((group) => !degenerate(group));
115
+ const rest = { ...(option as TQorusFormFieldSchema) };
116
+ delete rest.required_groups;
117
+ resolved[name] = {
118
+ ...rest,
119
+ required: true,
120
+ ...(kept.length ? { required_groups: kept } : {}),
121
+ } as TQorusFormFieldSchema;
122
+ });
123
+ return resolved as T;
124
+ };
@@ -0,0 +1,81 @@
1
+ import type { TMarkdownRenderer } from '../components/Description/markdownRendererContext';
2
+
3
+ /**
4
+ * What a template picker should actually list.
5
+ *
6
+ * Pickers group templates by category, which is what makes a long catalogue
7
+ * scannable. A LONE category buys nothing and costs a click: the author opens
8
+ * the picker, sees one row naming the category, clicks it, and only then sees
9
+ * the values — with a back arrow as the only other thing on offer. So a single
10
+ * category is opened for them.
11
+ *
12
+ * Two or more categories keep the grouping, because there the headers are doing
13
+ * real work. This only ever removes a step that could not have gone any other
14
+ * way.
15
+ *
16
+ * Lives here rather than beside either picker: `TemplateField` renders the
17
+ * "Select Template" dropdown and `RichText` renders the in-editor `$` list, and
18
+ * `TemplateField` already imports `RichText` — so a shared home is the only one
19
+ * that does not close an import cycle.
20
+ */
21
+ export const templateItemsToShow = <T,>(items: T[] | undefined): T[] | undefined => {
22
+ if (items?.length !== 1) {
23
+ return items;
24
+ }
25
+ const only = items[0] as { items?: unknown[] };
26
+ return Array.isArray(only.items) && only.items.length ? (only.items as T[]) : items;
27
+ };
28
+
29
+ /**
30
+ * A picker row's description, DRAWN.
31
+ *
32
+ * A description is prose, and a host that writes prose writes markdown: the
33
+ * same author, in the same dialect, as the field descriptions and schema
34
+ * messages this package already draws through the host's renderer. Left as a
35
+ * string, a row explaining that a step needs a **Fixture Output** printed the
36
+ * asterisks at the reader.
37
+ *
38
+ * WHERE THIS MAY BE CALLED, and why it is not simply done at the source: the
39
+ * template list is a memo KEY in several places — `TemplateField` stringifies
40
+ * both the incoming `templates` and the resolved `filteredTemplates`, and the
41
+ * list is handed down to nested fields that stringify it again. `JSON.stringify`
42
+ * throws on a React element's circular `_owner`, so an element anywhere in the
43
+ * list takes the whole form down to a blank panel.
44
+ *
45
+ * So this runs at the LAST hop only — the call that hands items to a Reqore
46
+ * control, after every key has been computed — and its result is never passed
47
+ * on to anything that might stringify it. Reqore itself does not: it draws the
48
+ * description into a `ReqoreTextEffect` and styled-components filters the prop
49
+ * off the DOM.
50
+ *
51
+ * Without a host renderer the items are returned untouched, so a host that has
52
+ * no markdown keeps exactly what it had.
53
+ */
54
+ export const renderTemplateItemDescriptions = <T,>(
55
+ items: T[] | undefined,
56
+ render: TMarkdownRenderer | undefined
57
+ ): T[] | undefined => {
58
+ if (!render || !items?.length) {
59
+ return items;
60
+ }
61
+
62
+ return items.map((item) => {
63
+ const entry = item as { description?: unknown; items?: unknown };
64
+ const next = { ...entry } as Record<string, unknown>;
65
+
66
+ if (typeof entry.description === 'string' && entry.description.trim()) {
67
+ /* `compact`, for the reason the context gives: a row is a constrained
68
+ container, and markdown authored as a document opens with a heading
69
+ that would outgrow the label above it. */
70
+ next.description = render({ value: entry.description, compact: true });
71
+ }
72
+
73
+ // Only when the item HAS children — writing the key unconditionally would
74
+ // give every leaf an `items` a picker could read as an empty submenu.
75
+ if (Array.isArray(entry.items)) {
76
+ next.items = renderTemplateItemDescriptions(entry.items, render);
77
+ }
78
+
79
+ return next as T;
80
+ });
81
+ };