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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/.claude/CLAUDE.md +25 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/STORY_NETWORK_ISOLATION.md +98 -0
  4. package/README.md +1 -1
  5. package/design/FORM_ENGINE_EXPRESSIONS.md +110 -0
  6. package/design/RENDER_EXPRESSION_TRANSPORT.md +18 -0
  7. package/dist/components/dpqlEditor/dpqlProbe.d.ts +37 -0
  8. package/dist/components/dpqlEditor/dpqlProbe.d.ts.map +1 -0
  9. package/dist/components/dpqlEditor/dpqlProbe.js +203 -0
  10. package/dist/components/dpqlEditor/dpqlProbe.js.map +1 -0
  11. package/dist/components/dpqlEditor/index.d.ts +4 -0
  12. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  13. package/dist/components/dpqlEditor/index.js +5 -1
  14. package/dist/components/dpqlEditor/index.js.map +1 -1
  15. package/dist/components/dpqlEditor/types.d.ts +29 -2
  16. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  17. package/dist/components/dpqlEditor/useDpqlProbe.d.ts +4 -0
  18. package/dist/components/dpqlEditor/useDpqlProbe.d.ts.map +1 -0
  19. package/dist/components/dpqlEditor/useDpqlProbe.js +29 -0
  20. package/dist/components/dpqlEditor/useDpqlProbe.js.map +1 -0
  21. package/dist/components/dpqlEditor/useDpqlSession.d.ts +5 -2
  22. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  23. package/dist/components/dpqlEditor/useDpqlSession.js +8 -2
  24. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  25. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  26. package/dist/components/form/engine/CompactRow.js +186 -21
  27. package/dist/components/form/engine/CompactRow.js.map +1 -1
  28. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  29. package/dist/components/form/engine/CompactToolbar.js +5 -2
  30. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  31. package/dist/components/form/engine/FormEngine.d.ts +159 -11
  32. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  33. package/dist/components/form/engine/FormEngine.js +376 -75
  34. package/dist/components/form/engine/FormEngine.js.map +1 -1
  35. package/dist/components/form/engine/FormFieldsSkeleton.d.ts +48 -0
  36. package/dist/components/form/engine/FormFieldsSkeleton.d.ts.map +1 -0
  37. package/dist/components/form/engine/FormFieldsSkeleton.js +38 -0
  38. package/dist/components/form/engine/FormFieldsSkeleton.js.map +1 -0
  39. package/dist/components/form/engine/OptionFieldMessages.d.ts +17 -2
  40. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  41. package/dist/components/form/engine/OptionFieldMessages.js +61 -11
  42. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  43. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  44. package/dist/components/form/engine/_structuredData/SchemaDataView.js +7 -1
  45. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  46. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  47. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  48. package/dist/components/form/engine/readFirst.d.ts +42 -1
  49. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  50. package/dist/components/form/engine/readFirst.js +74 -7
  51. package/dist/components/form/engine/readFirst.js.map +1 -1
  52. package/dist/components/form/engine/rendererTypes.d.ts +13 -0
  53. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -1
  54. package/dist/components/form/engine/rendererTypes.js.map +1 -1
  55. package/dist/components/form/engine/rowMenuContext.d.ts +78 -0
  56. package/dist/components/form/engine/rowMenuContext.d.ts.map +1 -0
  57. package/dist/components/form/engine/rowMenuContext.js +103 -0
  58. package/dist/components/form/engine/rowMenuContext.js.map +1 -0
  59. package/dist/components/form/engine/typeChoices.d.ts +37 -0
  60. package/dist/components/form/engine/typeChoices.d.ts.map +1 -0
  61. package/dist/components/form/engine/typeChoices.js +25 -0
  62. package/dist/components/form/engine/typeChoices.js.map +1 -0
  63. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  64. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  65. package/dist/components/form/expressions/ExpressionField.js +181 -33
  66. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  67. package/dist/components/form/expressions/argumentPresence.d.ts +97 -0
  68. package/dist/components/form/expressions/argumentPresence.d.ts.map +1 -0
  69. package/dist/components/form/expressions/argumentPresence.js +203 -0
  70. package/dist/components/form/expressions/argumentPresence.js.map +1 -0
  71. package/dist/components/form/expressions/builder/argumentLabel.d.ts.map +1 -1
  72. package/dist/components/form/expressions/builder/argumentLabel.js +9 -4
  73. package/dist/components/form/expressions/builder/argumentLabel.js.map +1 -1
  74. package/dist/components/form/expressions/builder/index.d.ts +15 -2
  75. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  76. package/dist/components/form/expressions/builder/index.js +19 -57
  77. package/dist/components/form/expressions/builder/index.js.map +1 -1
  78. package/dist/components/form/expressions/dpqlMockLsp.d.ts +17 -0
  79. package/dist/components/form/expressions/dpqlMockLsp.d.ts.map +1 -1
  80. package/dist/components/form/expressions/dpqlMockLsp.js +75 -9
  81. package/dist/components/form/expressions/dpqlMockLsp.js.map +1 -1
  82. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  83. package/dist/components/form/expressions/renderExpressionToText.js +94 -19
  84. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  85. package/dist/components/form/fields/Field.d.ts.map +1 -1
  86. package/dist/components/form/fields/Field.js +3 -1
  87. package/dist/components/form/fields/Field.js.map +1 -1
  88. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +11 -1
  89. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  90. package/dist/components/form/fields/allowed-values/AllowedValues.js +14 -2
  91. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  92. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  93. package/dist/components/form/fields/auto/AutoFormField.js +13 -2
  94. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  95. package/dist/components/form/fields/rich-text/RichText.d.ts +11 -0
  96. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  97. package/dist/components/form/fields/rich-text/RichText.js +84 -5
  98. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  99. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +2 -1
  100. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  101. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -2
  102. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  103. package/dist/components/form/fields/template/TemplateField.d.ts +31 -1
  104. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  105. package/dist/components/form/fields/template/TemplateField.js +507 -40
  106. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  107. package/dist/components/form/index.d.ts +1 -0
  108. package/dist/components/form/index.d.ts.map +1 -1
  109. package/dist/components/form/index.js +1 -0
  110. package/dist/components/form/index.js.map +1 -1
  111. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts +1 -0
  112. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts.map +1 -1
  113. package/dist/components/smartEditor/__fixtures__/mockLspServer.js +6 -1
  114. package/dist/components/smartEditor/__fixtures__/mockLspServer.js.map +1 -1
  115. package/dist/helpers/dpqlDetection.d.ts +58 -0
  116. package/dist/helpers/dpqlDetection.d.ts.map +1 -0
  117. package/dist/helpers/dpqlDetection.js +164 -0
  118. package/dist/helpers/dpqlDetection.js.map +1 -0
  119. package/dist/helpers/optionUiTypes.d.ts +1 -1
  120. package/dist/helpers/options.d.ts +37 -0
  121. package/dist/helpers/options.d.ts.map +1 -1
  122. package/dist/helpers/options.js +78 -1
  123. package/dist/helpers/options.js.map +1 -1
  124. package/dist/helpers/templateItems.d.ts +47 -0
  125. package/dist/helpers/templateItems.d.ts.map +1 -0
  126. package/dist/helpers/templateItems.js +88 -0
  127. package/dist/helpers/templateItems.js.map +1 -0
  128. package/dist/helpers/templates.d.ts +21 -0
  129. package/dist/helpers/templates.d.ts.map +1 -1
  130. package/dist/helpers/templates.js +49 -3
  131. package/dist/helpers/templates.js.map +1 -1
  132. package/dist/helpers/validations.d.ts.map +1 -1
  133. package/dist/helpers/validations.js +56 -9
  134. package/dist/helpers/validations.js.map +1 -1
  135. package/dist/hooks/useArgSchema.d.ts +2 -1
  136. package/dist/hooks/useArgSchema.d.ts.map +1 -1
  137. package/dist/hooks/useFetch/useFetch.d.ts +3 -1
  138. package/dist/hooks/useFetch/useFetch.d.ts.map +1 -1
  139. package/dist/hooks/useFetch/useFetch.js +47 -9
  140. package/dist/hooks/useFetch/useFetch.js.map +1 -1
  141. package/dist/hooks/useTemplates.d.ts +20 -0
  142. package/dist/hooks/useTemplates.d.ts.map +1 -1
  143. package/dist/hooks/useTemplates.js +34 -2
  144. package/dist/hooks/useTemplates.js.map +1 -1
  145. package/dist/index.d.ts +2 -1
  146. package/dist/index.d.ts.map +1 -1
  147. package/dist/index.js +5 -1
  148. package/dist/index.js.map +1 -1
  149. package/dist/stories/storyNetwork.d.ts +36 -0
  150. package/dist/stories/storyNetwork.d.ts.map +1 -0
  151. package/dist/stories/storyNetwork.js +82 -0
  152. package/dist/stories/storyNetwork.js.map +1 -0
  153. package/dist/utils/fetch.d.ts +151 -1
  154. package/dist/utils/fetch.d.ts.map +1 -1
  155. package/dist/utils/fetch.js +256 -44
  156. package/dist/utils/fetch.js.map +1 -1
  157. package/dist/utils/lspClient.d.ts +6 -0
  158. package/dist/utils/lspClient.d.ts.map +1 -1
  159. package/dist/utils/lspClient.js +62 -11
  160. package/dist/utils/lspClient.js.map +1 -1
  161. package/dist/utils/websocket.d.ts +6 -0
  162. package/dist/utils/websocket.d.ts.map +1 -1
  163. package/dist/utils/websocket.js +15 -7
  164. package/dist/utils/websocket.js.map +1 -1
  165. package/package.json +2 -2
  166. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +0 -2
  167. package/src/components/dpqlEditor/dpqlProbe.ts +165 -0
  168. package/src/components/dpqlEditor/index.ts +4 -0
  169. package/src/components/dpqlEditor/types.ts +26 -2
  170. package/src/components/dpqlEditor/useDpqlProbe.ts +33 -0
  171. package/src/components/dpqlEditor/useDpqlSession.ts +20 -4
  172. package/src/components/form/engine/CompactRow.tsx +231 -11
  173. package/src/components/form/engine/CompactToolbar.tsx +10 -3
  174. package/src/components/form/engine/FormEngine.stories.tsx +331 -30
  175. package/src/components/form/engine/FormEngine.tsx +504 -87
  176. package/src/components/form/engine/FormFieldsSkeleton.tsx +76 -0
  177. package/src/components/form/engine/OptionFieldMessages.tsx +89 -11
  178. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +11 -0
  179. package/src/components/form/engine/compactToolbarContext.ts +1 -1
  180. package/src/components/form/engine/readFirst.ts +86 -7
  181. package/src/components/form/engine/rendererTypes.ts +12 -0
  182. package/src/components/form/engine/rowMenuContext.ts +159 -0
  183. package/src/components/form/engine/typeChoices.ts +55 -0
  184. package/src/components/form/expressions/ExpressionField.stories.tsx +828 -31
  185. package/src/components/form/expressions/ExpressionField.tsx +234 -29
  186. package/src/components/form/expressions/argumentPresence.ts +200 -0
  187. package/src/components/form/expressions/builder/argumentLabel.tsx +10 -4
  188. package/src/components/form/expressions/builder/index.tsx +31 -75
  189. package/src/components/form/expressions/dpqlMockLsp.ts +81 -1
  190. package/src/components/form/expressions/renderExpressionToText.ts +106 -17
  191. package/src/components/form/fields/Field.tsx +3 -2
  192. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -2
  193. package/src/components/form/fields/auto/AutoFormField.tsx +12 -2
  194. package/src/components/form/fields/rich-text/RichText.tsx +112 -19
  195. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -2
  196. package/src/components/form/fields/template/TemplateField.tsx +667 -95
  197. package/src/components/form/index.tsx +1 -0
  198. package/src/components/log/Log.stories.tsx +2 -1
  199. package/src/components/smartEditor/__fixtures__/mockLspServer.ts +6 -1
  200. package/src/helpers/dpqlDetection.ts +209 -0
  201. package/src/helpers/options.ts +70 -0
  202. package/src/helpers/templateItems.ts +81 -0
  203. package/src/helpers/templates.ts +51 -2
  204. package/src/helpers/validations.ts +59 -9
  205. package/src/hooks/useFetch/useFetch.stories.tsx +6 -5
  206. package/src/hooks/useFetch/useFetch.tsx +53 -7
  207. package/src/hooks/useStorage/useStorage.stories.tsx +2 -1
  208. package/src/hooks/useTemplates.ts +41 -0
  209. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +2 -1
  210. package/src/index.tsx +14 -1
  211. package/src/stores/currentUser/currentUser.stories.tsx +2 -1
  212. package/src/stories/storyNetwork.ts +92 -0
  213. package/src/utils/fetch.ts +311 -21
  214. package/src/utils/lspClient.ts +68 -11
  215. package/src/utils/websocket.ts +20 -9
@@ -29,7 +29,7 @@ import {
29
29
  } from '@qoretechnologies/reqore/dist/components/Textarea';
30
30
  import { IQorusFormFieldSchemaBase, TQorusType } from '@qoretechnologies/ts-toolkit';
31
31
  import { size } from 'lodash';
32
- import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
32
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
33
  import { useUpdateEffect } from 'react-use';
34
34
  import {
35
35
  filterTemplatesByType as templatesFilterFunc,
@@ -40,7 +40,14 @@ import {
40
40
  describeTemplateReference,
41
41
  isValueTemplate,
42
42
  } from '../../../../helpers/templates';
43
+ import { classifyTypedText, mightBeDpqlExpression } from '../../../../helpers/dpqlDetection';
44
+ import {
45
+ renderTemplateItemDescriptions,
46
+ templateItemsToShow,
47
+ } from '../../../../helpers/templateItems';
48
+ import { useMarkdownRenderer } from '../../../Description/markdownRendererContext';
43
49
  import { getTypeFromValue } from '../../../../helpers/validations';
50
+ import { useDpqlProbe } from '../../../dpqlEditor/useDpqlProbe';
44
51
  import { useQorusTypes } from '../../../../hooks/useQorusTypes';
45
52
  import { useWhyDidYouUpdate } from '../../../../hooks/useWhyDidYouUpdate';
46
53
  import { ExpressionBuilder, IExpressionBuilderProps } from '../../expressions/builder';
@@ -57,11 +64,26 @@ import LongStringFormField from '../long-string/LongString';
57
64
  import NumberFormField from '../number/Number';
58
65
  import { ReadOnlyTemplateTag } from './ReadOnlyTemplateTag';
59
66
  import { RichTextFormField } from '../rich-text/RichText';
67
+ import { richtextToString } from '../../../../helpers/common';
68
+ import {
69
+ IRowMenuRegistration,
70
+ RowMenuContext,
71
+ useRowMenu,
72
+ useRowMenuPublisher,
73
+ } from '../../engine/rowMenuContext';
60
74
 
61
75
  // Re-export template utilities for consumers
62
76
  export { getTemplateKey, getTemplateValue, isValueTemplate };
63
77
  export type { IQorusFormType as IQorusType };
64
78
 
79
+ /**
80
+ * How long the author must pause before typed text is sent to the DPQL
81
+ * probe. Long enough that a probe is not sent for every prefix of what is
82
+ * being typed, short enough that the offer appears while they are still
83
+ * looking at the field.
84
+ */
85
+ const DPQL_DETECT_DEBOUNCE_MS = 400;
86
+
65
87
  export const TemplatesListProps: IReqoreDropdownProps = {
66
88
  useTargetWidth: true,
67
89
  handler: 'focus',
@@ -160,6 +182,14 @@ export interface ITemplateFieldProps extends Partial<
160
182
  * out of `rest` and never spread onto a leaf input.
161
183
  */
162
184
  extraActions?: IExpressionBuilderProps['extraActions'];
185
+ /**
186
+ * SEAM (reqraft): the host's per-`ui_type` editors, forwarded to the field
187
+ * component and to the expression editor's operand rows. Declared here — not
188
+ * left to the index signature — for the same reason as `extraActions` above:
189
+ * so it is destructured out of `rest` and never spread onto a leaf input or a
190
+ * Reqore layout component, both of which pass unknown props to the DOM.
191
+ */
192
+ componentOverrides?: Record<string, React.FC<any>>;
163
193
  [key: string]: any;
164
194
  default_value?: unknown;
165
195
  }
@@ -297,10 +327,9 @@ export const CustomMenuItems = memo(
297
327
  return (
298
328
  <ReqoreMenuSection label='Set Custom Value' isCollapsed transparent icon='Text' {...rest}>
299
329
  {items.map((menuItem, index) =>
300
- 'isDivider' in menuItem ? (
330
+ 'isDivider' in menuItem ?
301
331
  <ReqoreMenuDivider key={index} {...menuItem} />
302
- ) : (
303
- <ReqoreButton
332
+ : <ReqoreButton
304
333
  {...(rest as any)}
305
334
  {...menuItem}
306
335
  key={index}
@@ -312,7 +341,6 @@ export const CustomMenuItems = memo(
312
341
  });
313
342
  }}
314
343
  />
315
- )
316
344
  )}
317
345
  </ReqoreMenuSection>
318
346
  );
@@ -331,6 +359,15 @@ export const TemplateDropdownSelector = memo(
331
359
  size,
332
360
  ...rest
333
361
  }: ITemplateDropdownSelectorProps) => {
362
+ /* The LAST hop before Reqore, which is the only place a drawn description
363
+ may be made: `filteredTemplates` upstream is a `JSON.stringify` memo key,
364
+ and an element in it throws on its own circular owner. */
365
+ const renderMarkdown = useMarkdownRenderer();
366
+ const shownItems = useMemo(
367
+ () => renderTemplateItemDescriptions(templateItemsToShow(items), renderMarkdown),
368
+ [items, renderMarkdown]
369
+ );
370
+
334
371
  // One resolver for every surface that names a reference — the picker chip
335
372
  // here, the read-only tag, and the compact row's expression summary. The
336
373
  // row and this editor must agree: a reference that reads as its path when
@@ -364,7 +401,7 @@ export const TemplateDropdownSelector = memo(
364
401
  minimal
365
402
  compact
366
403
  onItemSelect={onItemSelect}
367
- items={items}
404
+ items={shownItems}
368
405
  label={label}
369
406
  leftIconProps={leftIconProps}
370
407
  caretPosition='right'
@@ -372,7 +409,7 @@ export const TemplateDropdownSelector = memo(
372
409
  size={size}
373
410
  {...TemplatesListProps}
374
411
  />
375
- {allowCustomValues || value ? (
412
+ {allowCustomValues || value ?
376
413
  <ReqoreButton
377
414
  customTheme={TemplatesListProps.listCustomTheme}
378
415
  fixed
@@ -384,15 +421,48 @@ export const TemplateDropdownSelector = memo(
384
421
  size={size}
385
422
  onClick={onRemoveClick}
386
423
  />
387
- ) : null}
424
+ : null}
388
425
  </ReqoreControlGroup>
389
426
  </ReqoreControlGroup>
390
427
  );
391
428
  }
392
429
  );
393
430
 
394
- export const TemplateField = memo(
431
+ /**
432
+ * `ui_type`s whose OWN editor renders templates as chips inline.
433
+ *
434
+ * For these the template SELECTOR must not take over: it replaces a control the
435
+ * author can type in with one they can only pick from. A Qorus test assertion's
436
+ * `Value` is the case this was written for — the IDE's own `auto.tsx` already
437
+ * renders it as template rich text (type freely, references become chips), and
438
+ * the same field reached through this form engine offered a dropdown and no way
439
+ * to type at all.
440
+ *
441
+ * `richtext` is reqraft's own; a consumer adds its types through the
442
+ * `templateAwareUiTypes` prop, exactly as it adds `rendererOnlyUiTypes`.
443
+ */
444
+ export const BuiltInTemplateAwareUiTypes = ['richtext'];
445
+
446
+ export const isTemplateAwareUiType = (uiType?: string, extra?: string[]): boolean =>
447
+ !!uiType && [...BuiltInTemplateAwareUiTypes, ...(extra ?? [])].includes(uiType);
448
+
449
+ /**
450
+ * One entry of the "set a value of this type" menu an untyped field offers.
451
+ *
452
+ * `onClick` is typed by what the caller actually invokes it with — the chosen
453
+ * value and a reset callback — rather than as `Function`, which accepts a class
454
+ * declaration as readily as a handler and gives no help at the call site.
455
+ */
456
+ interface IUntypedFieldMenuItem {
457
+ label?: unknown;
458
+ description?: unknown;
459
+ isDivider?: boolean;
460
+ onClick?: (value: unknown, reset: () => void) => void;
461
+ }
462
+
463
+ const TemplateFieldImpl = memo(
395
464
  ({
465
+ rowMenu,
396
466
  value,
397
467
  name,
398
468
  onChange,
@@ -403,6 +473,7 @@ export const TemplateField = memo(
403
473
  allowFunctions,
404
474
  allowTextExpressions,
405
475
  extraActions,
476
+ componentOverrides,
406
477
  allowCustomValues = true,
407
478
  filterTemplatesByType = true,
408
479
  filterTemplatesFunc,
@@ -418,7 +489,7 @@ export const TemplateField = memo(
418
489
  reorder,
419
490
  label,
420
491
  ...rest
421
- }: ITemplateFieldProps) => {
492
+ }: ITemplateFieldProps & { rowMenu?: IRowMenuRegistration }) => {
422
493
  const qorusTypes = useQorusTypes();
423
494
  const functions = useExpressions({
424
495
  allow: !!allowFunctions,
@@ -427,9 +498,22 @@ export const TemplateField = memo(
427
498
  });
428
499
  const type = rest.ui_type || rest.type || rest.defaultType;
429
500
 
501
+ /* The type an EXPRESSION must return, and is stored with, is a DATA type.
502
+ `type` above deliberately prefers `ui_type`, because that is what decides
503
+ which editor to render — but a ui_type says how a value is EDITED, not
504
+ what it is. The IDE's reference fields carry `ui_type: 'test-reference'`
505
+ (a rich-text editor that turns `$.` paths into chips), and feeding that
506
+ to the expression builder asked it for an expression RETURNING
507
+ `test-reference`: no expression returns one, so every choice was refused
508
+ with *"the expected return type is test-reference"*, and the ui_type was
509
+ then stored on the saved expression as its declared type.
510
+
511
+ The declared data type is the constraint; the ui_type is only a fallback
512
+ for a field that declares no type at all. */
513
+ const expressionDataType = rest.type || rest.defaultType || rest.ui_type;
514
+
430
515
  const filteredTemplates = useMemo<IReqoreFormTemplates>(():
431
- | IReqoreFormTemplates
432
- | undefined => {
516
+ IReqoreFormTemplates | undefined => {
433
517
  if (!allowTemplates) {
434
518
  return undefined;
435
519
  }
@@ -444,7 +528,20 @@ export const TemplateField = memo(
444
528
  result = filterTemplatesFunc(result);
445
529
  }
446
530
 
447
- return result;
531
+ /* A lone category is opened, once, HERE — where this control resolves the
532
+ templates every one of its pickers then reads: the "Select Template"
533
+ dropdown, the in-editor `$` list, the numeric field's focus dropdown and
534
+ the expression builder's argument picker. Applying it per picker meant
535
+ each new one had to remember, and the ones that forgot made the author
536
+ click through a header naming the only category on offer to reach the
537
+ only values on offer.
538
+
539
+ LAST, after both filters. `filterTemplatesFunc` is given the grouped
540
+ shape on purpose — the Qog expression builder filters top-level items by
541
+ `metadata.dataRole`, which is a property of the CATEGORY — so flattening
542
+ before it would hand it values and quietly change what it keeps. This
543
+ only changes what is finally shown. */
544
+ return { ...result, items: templateItemsToShow(result?.items) };
448
545
  }, [
449
546
  JSON.stringify(templates),
450
547
  type,
@@ -473,20 +570,58 @@ export const TemplateField = memo(
473
570
  // templates on offer would otherwise open on an EMPTY picker, which is a
474
571
  // worse place to start than the type picker it replaced.
475
572
  const typeIsAnyLike = type === 'any' || type === 'auto';
476
- const isEmptyValue = value === undefined || value === null || value === '';
573
+ /* An EMPTY field, for the purpose of offering the template selector.
574
+
575
+ `null` is deliberately not empty. It is the DPQL null literal — a value
576
+ an author writes to say "this is nothing", and the only way to assert
577
+ that a call returned no value. Counted as emptiness here, an argument
578
+ holding it was treated as a field the author had just cleared: with a
579
+ type of `any` and templates on offer, the selector-restore effect below
580
+ flipped the field into template mode with a null template value, and
581
+ that reported the field as cleared — so writing `null` in the Text tab
582
+ and switching to Visual silently erased it, leaving `{type: "any"}` in
583
+ the draft and the message "Value for argument 1 ("any") is invalid:
584
+ Value is empty".
585
+
586
+ `undefined` is the cleared state; `null` is a value. Same rule as
587
+ `expressions/argumentPresence`, which states it canonically. */
588
+ const isEmptyValue = value === undefined || value === '';
477
589
  const hasTemplatesOnOffer = !!size(filteredTemplates?.items);
478
- const [isTemplate, setIsTemplate] = useState<boolean>(
590
+ /* The field's own editor already renders templates inline, so the selector
591
+ is not merely unnecessary here — it is a downgrade, swapping a typable
592
+ control for a pick-only one. Derived rather than folded into the state
593
+ below so every `setIsTemplate` path keeps working untouched; they simply
594
+ stop having anything to say for these fields. */
595
+ const editorHandlesTemplates =
596
+ !!allowTemplates && isTemplateAwareUiType(type as string, (rest as any).templateAwareUiTypes);
597
+
598
+ const [isTemplateState, setIsTemplate] = useState<boolean>(
479
599
  (isDefaultTemplate ||
480
600
  isValueTemplate(value) ||
481
601
  !allowCustomValues ||
482
602
  (typeIsAnyLike && isEmptyValue && hasTemplatesOnOffer)) &&
483
603
  allowTemplates
484
604
  );
605
+
606
+ const isTemplate = editorHandlesTemplates ? false : isTemplateState;
485
607
  const [internalIsFunction, setInternalIsFunction] = useState<boolean>(
486
608
  !!isDefaultFunction && !!allowFunctions
487
609
  );
488
610
  const [templateValue, setTemplateValue] = useState<string | null>(value);
489
611
 
612
+ // Typed text that turned out to be an expression the author may accept.
613
+ const [dpqlOffer, setDpqlOffer] = useState<{ text: string; expression: any } | null>(null);
614
+ // The literal this field held before typed text switched it into
615
+ // expression mode — kept so the switch is undoable, and so the editor
616
+ // opens on the Text view the author was already writing in.
617
+ const [expressionFromText, setExpressionFromText] = useState<string | null>(null);
618
+ // Texts the author has said no to. Without this, dismissing an offer (or
619
+ // undoing a switch) would re-detect the same text on the next render and
620
+ // ask again forever.
621
+ const declinedTexts = useRef<Set<string>>(new Set());
622
+ const detectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
623
+ const probeDpql = useDpqlProbe();
624
+
490
625
  const effectiveIsFunction = isFunction || internalIsFunction;
491
626
 
492
627
  useWhyDidYouUpdate(`Template field ${name}`, {
@@ -502,6 +637,65 @@ export const TemplateField = memo(
502
637
  }
503
638
  }, [JSON.stringify(value)]);
504
639
 
640
+ /* Clearing an untyped field returns it to the template selector, which is
641
+ where mounting it empty already lands.
642
+
643
+ The rule above that opens an `any`-like field on the template selector is
644
+ a `useState` INITIALISER, so it only ever ran on mount. Clearing a value
645
+ in place does not remount anything, so the field fell through to the TYPE
646
+ picker and demanded `string`/`int`/`hash` before it would let the author
647
+ name a value they had already captured -- the very question that rule
648
+ exists to stop asking. Reloading the page fixed it, which is the tell:
649
+ same field, same empty value, different landing, because only one of the
650
+ two paths ran the rule.
651
+
652
+ Watched as a TRANSITION from a value to no value, not as "the value is
653
+ empty": an author who picks `Set Custom Value` on an empty field is
654
+ asking for the type picker, and re-asserting the template view on every
655
+ render while the field sat empty would take it away again immediately. */
656
+ const hadValue = useRef(!isEmptyValue);
657
+ /* A clear is REMEMBERED rather than acted on in the same render.
658
+
659
+ While the field holds a value its resolved type is that VALUE's type — a
660
+ reference reads as `test-reference`, not `auto` — and clearing reverts it
661
+ to the schema's `auto` one render LATER than the value empties. Acting
662
+ only on the transition therefore ran while `typeIsAnyLike` was still
663
+ false and did nothing, and by the time the type settled the transition
664
+ had passed. Measured in the live IDE at the moment of the clear:
665
+ `{ wasCleared: true, typeIsAnyLike: false, type: "test-reference",
666
+ hasTemplatesOnOffer: true }`, then `typeIsAnyLike: true` on the very next
667
+ render with `wasCleared` already false. */
668
+ const restoreSelectorWhenSettled = useRef(false);
669
+ /** Set by the template control's own `×`, which is an author saying "not a
670
+ * template" — that clear must not be answered with the selector again. */
671
+ const suppressSelectorRestore = useRef(false);
672
+
673
+ useEffect(() => {
674
+ if (hadValue.current && isEmptyValue) {
675
+ restoreSelectorWhenSettled.current = !suppressSelectorRestore.current;
676
+ suppressSelectorRestore.current = false;
677
+ }
678
+ if (!isEmptyValue) {
679
+ // A value arrived: whatever the author did, they are not sitting on an
680
+ // empty field waiting to be offered the list.
681
+ restoreSelectorWhenSettled.current = false;
682
+ }
683
+ hadValue.current = !isEmptyValue;
684
+
685
+ if (
686
+ restoreSelectorWhenSettled.current &&
687
+ isEmptyValue &&
688
+ typeIsAnyLike &&
689
+ hasTemplatesOnOffer &&
690
+ allowTemplates &&
691
+ !effectiveIsFunction
692
+ ) {
693
+ restoreSelectorWhenSettled.current = false;
694
+ setTemplateValue(null);
695
+ setIsTemplate(true);
696
+ }
697
+ }, [isEmptyValue, typeIsAnyLike, hasTemplatesOnOffer, allowTemplates, effectiveIsFunction]);
698
+
505
699
  useEffect(() => {
506
700
  if (allowCustomValues && isTemplate && value && !isValueTemplate(value)) {
507
701
  setIsTemplate(false);
@@ -522,6 +716,17 @@ export const TemplateField = memo(
522
716
  return;
523
717
  }
524
718
 
719
+ // A template reference with an OPERATOR on it (`$local:count + 1`) is
720
+ // an expression, not a template. The loose check above passes it —
721
+ // it starts with `$` and has a colon — so without this every such
722
+ // string was swallowed into template mode, where the arithmetic is
723
+ // dead text and no affordance can reach it. A token standing alone
724
+ // (`$local:count`, `$data:{1.field}`) has no operator and still
725
+ // flips, which is the case template mode is for.
726
+ if (!isCompleteTemplateToken(value) && mightBeDpqlExpression(value)) {
727
+ return;
728
+ }
729
+
525
730
  setIsTemplate(true);
526
731
  setTemplateValue(value);
527
732
  }
@@ -550,8 +755,23 @@ export const TemplateField = memo(
550
755
  // the rich-text string mode.
551
756
  const templateValueIsBracedToken = isBracedTemplateToken(templateValue);
552
757
 
758
+ /* An UNTYPED field can be typed into as well as picked from.
759
+
760
+ Template mode has a typable editor whenever the field's type can hold
761
+ arbitrary text, and `any`/`auto` can — it is the type that has not been
762
+ narrowed yet, not a type that excludes text. Restricting this to
763
+ `string` meant an empty untyped field opened on template mode with no
764
+ editor to offer, so it fell through to the pick-only "Select Template"
765
+ dropdown: a control that can only choose from a list, on exactly the
766
+ fields where an author most often needs to write something the list
767
+ cannot hold — a deeper walk, a literal, or an expression.
768
+
769
+ Reported against a Qorus assertion's Expected Value, and it was never
770
+ specific to that field: every empty untyped field with templates on
771
+ offer got the same downgrade. The editor still offers the same templates
772
+ on focus, so nothing is lost by being able to type as well. */
553
773
  const templateSupportsCustomValues =
554
- allowCustomValues && type === 'string' && !hasOnlyAllowedValues;
774
+ allowCustomValues && (type === 'string' || typeIsAnyLike) && !hasOnlyAllowedValues;
555
775
  const showTemplatesDropdown =
556
776
  allowTemplates && (!allowCustomValues || (isTemplate && !templateSupportsCustomValues));
557
777
  const hasOnlyExpressions = !allowCustomValues && !allowTemplates && allowFunctions;
@@ -572,7 +792,6 @@ export const TemplateField = memo(
572
792
  const componentTypeProp = componentFromType ? { type } : {};
573
793
  const fieldAriaLabel = rest['aria-label'] ?? label ?? rest.display_name ?? name;
574
794
 
575
-
576
795
  const handleTemplateFieldChange = useCallback(
577
796
  (_name: string, val: string) => {
578
797
  if (!val) {
@@ -591,24 +810,38 @@ export const TemplateField = memo(
591
810
  (item) => {
592
811
  // If the template type is richtext, we need to wrap the template value in the richtext template format
593
812
  const value =
594
- item.badge === 'richtext'
595
- ? ([
596
- {
597
- type: 'paragraph',
598
- children: [
599
- {
600
- children: [{ text: '' }],
601
- label: item.label,
602
- type: 'tag',
603
- value: item.value,
604
- metadata: item.metadata,
605
- },
606
- ],
607
- },
608
- ] as IReqoreRichTextEditorProps['value'])
609
- : item.value;
610
-
611
- onChange(name, value, hasOnlyAllowedValues ? (type as TQorusType) : (item.badge as TQorusType));
813
+ item.badge === 'richtext' ?
814
+ ([
815
+ {
816
+ type: 'paragraph',
817
+ /* The empty text nodes on either side are load-bearing. A
818
+ chip is an inline VOID: it holds no text of its own, so the
819
+ only places a cursor can go are the text nodes AROUND it.
820
+ Slate treats that as an invariant and repairs it during
821
+ normalisation, which runs on edits but NOT on a document
822
+ handed in whole as a controlled value — which is what this
823
+ is. Without them the picked template renders as a chip that
824
+ cannot be typed after, so it can never be extended by hand. */
825
+ children: [
826
+ { text: '' },
827
+ {
828
+ children: [{ text: '' }],
829
+ label: item.label,
830
+ type: 'tag',
831
+ value: item.value,
832
+ metadata: item.metadata,
833
+ },
834
+ { text: '' },
835
+ ],
836
+ },
837
+ ] as IReqoreRichTextEditorProps['value'])
838
+ : item.value;
839
+
840
+ onChange(
841
+ name,
842
+ value,
843
+ hasOnlyAllowedValues ? (type as TQorusType) : (item.badge as TQorusType)
844
+ );
612
845
  },
613
846
  [name, onChange]
614
847
  );
@@ -618,7 +851,15 @@ export const TemplateField = memo(
618
851
  // IDE-only, so the menu item is dropped (`allowSaving` is inert).
619
852
 
620
853
  const handleRemoveTemplateClick = useCallback(() => {
854
+ /* This is the `×` ON the template control, and it means "I do not want a
855
+ template here" — on a field whose menu offers no `Set Custom Value` it
856
+ is the ONLY way to reach a literal. So it still drops to the custom
857
+ value editor, and it tells the empty-field rule below to keep its hands
858
+ off this particular clear: that rule watches the value going away, and
859
+ this handler clears the value too, so without the flag it would send
860
+ the author straight back to the selector they just dismissed. */
621
861
  if (allowCustomValues) {
862
+ suppressSelectorRestore.current = true;
622
863
  setIsTemplate(false);
623
864
  }
624
865
 
@@ -668,12 +909,245 @@ export const TemplateField = memo(
668
909
  (expressionValue: IExpression | undefined, remove: boolean) => {
669
910
  if (remove) {
670
911
  setInternalIsFunction(false);
912
+ // The expression is gone, so there is no longer a switch to undo.
913
+ setExpressionFromText(null);
671
914
  }
672
- onChange(name, expressionValue?.value, type as TQorusType, !remove);
915
+ onChange(name, expressionValue?.value, expressionDataType as TQorusType, !remove);
673
916
  },
674
- [name, onChange, type, value]
917
+ [name, onChange, expressionDataType, value]
918
+ );
919
+
920
+ // ─── Text typed into a plain field that is really a DPQL expression ───
921
+ //
922
+ // A field that accepts expressions still gets DPQL TYPED into it, in the
923
+ // ordinary editor, by an author who never opened the expression view.
924
+ // What happens then depends on the field's own type, and the asymmetry is
925
+ // deliberate: text that could stand as a literal here is only ever
926
+ // OFFERED, because silently rewriting the string `a + b` into a
927
+ // concatenation would destroy a value the author meant. Text that could
928
+ // NOT be a literal here was already an error the moment it was typed, so
929
+ // switching costs nothing and explains the error.
930
+ //
931
+ // The server decides whether the text is an expression at all — see
932
+ // `helpers/dpqlDetection` for why a successful parse alone means nothing.
933
+ const validationField = useMemo(
934
+ () => ({
935
+ validation_regex: rest.validation_regex,
936
+ has_to_be_valid_identifier: rest.has_to_be_valid_identifier,
937
+ has_to_have_value: rest.has_to_have_value,
938
+ rules: rest.rules,
939
+ arg_schema: rest.arg_schema,
940
+ }),
941
+ [
942
+ rest.validation_regex,
943
+ rest.has_to_be_valid_identifier,
944
+ rest.has_to_have_value,
945
+ JSON.stringify(rest.rules),
946
+ JSON.stringify(rest.arg_schema),
947
+ ]
675
948
  );
676
949
 
950
+ const enterExpressionFromText = useCallback(
951
+ (text: string, expression: any) => {
952
+ setDpqlOffer(null);
953
+ setExpressionFromText(text);
954
+ setInternalIsFunction(true);
955
+ setIsTemplate(false);
956
+ setTemplateValue(null);
957
+ // `dpql/parse` answers with the field-ready envelope
958
+ // (`{is_expression, value}`); this field stores the inner AST and
959
+ // signals the flag through `onChange`'s fourth argument, exactly as
960
+ // `handleExpressionChange` does.
961
+ onChange?.(name, expression?.value ?? expression, expressionDataType as TQorusType, true);
962
+ },
963
+ [name, onChange, expressionDataType]
964
+ );
965
+
966
+ const undoExpressionFromText = useCallback(() => {
967
+ if (expressionFromText === null) {
968
+ return;
969
+ }
970
+
971
+ declinedTexts.current.add(expressionFromText);
972
+ setInternalIsFunction(false);
973
+ setExpressionFromText(null);
974
+ onChange?.(name, expressionFromText, type as TQorusType, false);
975
+ }, [expressionFromText, name, onChange, type]);
976
+
977
+ const declineDpqlOffer = useCallback(() => {
978
+ if (dpqlOffer) {
979
+ declinedTexts.current.add(dpqlOffer.text);
980
+ }
981
+
982
+ setDpqlOffer(null);
983
+ }, [dpqlOffer]);
984
+
985
+ const acceptDpqlOffer = useCallback(() => {
986
+ if (dpqlOffer) {
987
+ enterExpressionFromText(dpqlOffer.text, dpqlOffer.expression);
988
+ }
989
+ }, [dpqlOffer, enterExpressionFromText]);
990
+
991
+ // Held in a ref so the debounce below does not depend on it. `onChange`
992
+ // comes from the host form and can be a fresh function on any render;
993
+ // with the callback in the effect's deps, a re-render caused by a
994
+ // DIFFERENT field would restart this field's timer, and a form the
995
+ // author is typing in re-renders constantly.
996
+ const enterExpressionRef = useRef(enterExpressionFromText);
997
+ useEffect(() => {
998
+ enterExpressionRef.current = enterExpressionFromText;
999
+ }, [enterExpressionFromText]);
1000
+
1001
+ // Detection only runs on a field that could hold an expression and is
1002
+ // currently showing a plain editor — never on a read-only field, a
1003
+ // fixed-value list, or one already in template or expression mode.
1004
+ const canDetectDpql =
1005
+ !!allowFunctions &&
1006
+ !!allowTextExpressions &&
1007
+ !effectiveIsFunction &&
1008
+ !isTemplate &&
1009
+ !hasOnlyAllowedValues &&
1010
+ !rest.readonly &&
1011
+ !rest.readOnly &&
1012
+ !rest.disabled;
1013
+
1014
+ useEffect(() => {
1015
+ if (detectTimer.current) {
1016
+ clearTimeout(detectTimer.current);
1017
+ detectTimer.current = null;
1018
+ }
1019
+
1020
+ /* A richtext field is still text. It hands its value back as a Slate
1021
+ DOCUMENT rather than a flattened string — deliberately, so a chosen
1022
+ reference cannot fuse with text typed beside it — and reading only
1023
+ `typeof value === 'string'` here silently switched detection off for
1024
+ every one of them the moment the field changed shape. A test
1025
+ assertion's Value is exactly that field, and `1 + 2` typed into it
1026
+ stopped being offered as an expression.
1027
+
1028
+ `richtextToString` flattens a tag to its raw value, so a document
1029
+ holding only a chosen reference reads as `$.result` — no operator, so
1030
+ `mightBeDpqlExpression` rejects it below and the server is never
1031
+ asked. */
1032
+ const text =
1033
+ typeof value === 'string' ? value
1034
+ : Array.isArray(value) ? richtextToString(value as never)
1035
+ : undefined;
1036
+
1037
+ if (
1038
+ !canDetectDpql ||
1039
+ !text ||
1040
+ declinedTexts.current.has(text) ||
1041
+ !mightBeDpqlExpression(text)
1042
+ ) {
1043
+ setDpqlOffer(null);
1044
+ return undefined;
1045
+ }
1046
+
1047
+ let cancelled = false;
1048
+
1049
+ detectTimer.current = setTimeout(() => {
1050
+ detectTimer.current = null;
1051
+
1052
+ void probeDpql(text)
1053
+ .then((parsed) => {
1054
+ if (cancelled) {
1055
+ return;
1056
+ }
1057
+
1058
+ const outcome = classifyTypedText({ text, type, field: validationField, parsed });
1059
+
1060
+ if (outcome === 'switch') {
1061
+ enterExpressionRef.current(text, parsed.expression);
1062
+ } else if (outcome === 'offer') {
1063
+ setDpqlOffer({ text, expression: parsed.expression });
1064
+ } else {
1065
+ setDpqlOffer(null);
1066
+ }
1067
+ })
1068
+ .catch(() => {
1069
+ // Detection is an offer of help, never a reason for a field to
1070
+ // break: an unreachable instance or a classifier that threw leaves
1071
+ // the author's text exactly where they put it.
1072
+ if (!cancelled) {
1073
+ setDpqlOffer(null);
1074
+ }
1075
+ });
1076
+ }, DPQL_DETECT_DEBOUNCE_MS);
1077
+
1078
+ return () => {
1079
+ cancelled = true;
1080
+
1081
+ if (detectTimer.current) {
1082
+ clearTimeout(detectTimer.current);
1083
+ detectTimer.current = null;
1084
+ }
1085
+ };
1086
+ }, [value, canDetectDpql, type, validationField, probeDpql]);
1087
+
1088
+ const canOfferExpression =
1089
+ allowFunctions && !hasOnlyAllowedValues && !rest.readonly && !internalIsFunction;
1090
+ const canOfferTemplate = showTemplateToggle && !isTemplate;
1091
+
1092
+ const publishedItems = useMemo(
1093
+ () => [
1094
+ ...(canOfferExpression && !functions.loading
1095
+ ? [
1096
+ {
1097
+ label: 'Use Expression',
1098
+ icon: 'Functions' as const,
1099
+ tooltip: 'Run a function on this value',
1100
+ onClick: handleSelectFunctionChange,
1101
+ },
1102
+ ]
1103
+ : []),
1104
+ ...(canOfferTemplate
1105
+ ? [
1106
+ {
1107
+ label: 'Use Template',
1108
+ icon: 'MoneyDollarCircleLine' as const,
1109
+ tooltip: 'Use a template',
1110
+ onClick: handleTemplateToggleClick,
1111
+ },
1112
+ ]
1113
+ : []),
1114
+ /* The "set a value of this type" choices an untyped field offers.
1115
+ Dividers are dropped: they grouped items in a menu this field drew
1116
+ itself, and in the row's shared menu they would divide other
1117
+ people's. */
1118
+ ...((menuItems ?? []) as IUntypedFieldMenuItem[])
1119
+ .filter((item) => !('isDivider' in item))
1120
+ .map((item) => ({
1121
+ label: item.label as string,
1122
+ description: item.description as string | undefined,
1123
+ onClick: () =>
1124
+ item.onClick?.(undefined, () => {
1125
+ setIsTemplate(false);
1126
+ setTemplateValue(null);
1127
+ }),
1128
+ })),
1129
+ ],
1130
+ [
1131
+ canOfferExpression,
1132
+ functions.loading,
1133
+ canOfferTemplate,
1134
+ menuItems,
1135
+ handleSelectFunctionChange,
1136
+ handleTemplateToggleClick,
1137
+ ]
1138
+ );
1139
+
1140
+ /* Keyed on WHICH items are offered, never on the items: they carry
1141
+ handlers, so they are a new array every render and a row comparing them
1142
+ by value would loop. */
1143
+ const publishedKey = [
1144
+ canOfferExpression && !functions.loading ? 'expression' : '',
1145
+ canOfferTemplate ? 'template' : '',
1146
+ `custom:${((menuItems ?? []) as { label?: unknown }[]).map((item) => String(item.label ?? '')).join('|')}`,
1147
+ ].join(',');
1148
+
1149
+ useRowMenuPublisher(rowMenu, publishedKey, publishedItems as never);
1150
+
677
1151
  const renderControls = useCallback(() => {
678
1152
  const showFunctionsDropdown =
679
1153
  allowFunctions && !hasOnlyAllowedValues && !rest.readonly && !internalIsFunction;
@@ -681,33 +1155,44 @@ export const TemplateField = memo(
681
1155
  // The "Set value" label promises a way to set one — reorder rows alone don't.
682
1156
  const hasValueRows = showFunctionsDropdown || showTemplatesButton || size(menuItems) > 0;
683
1157
 
1158
+ /* ONE menu per control. Where this field sits in a form ROW, the row
1159
+ already renders a ⋮ of its own, and drawing a second one beside it
1160
+ put two menus of slightly different widths on the same control —
1161
+ with the type choices an `any` field offers hidden in the narrower
1162
+ one. The items are published to the row instead (see
1163
+ `rowMenuContext`); outside a row there is nothing to publish into,
1164
+ so the field goes on drawing its own. */
1165
+ if (rowMenu) {
1166
+ return null;
1167
+ }
1168
+
684
1169
  if (hasOnlyExpressions) {
685
- return showFunctionsDropdown ? (
686
- functions.loading ? (
687
- <ReqoreSkeleton size={rest.size} />
688
- ) : (
689
- <ReqoreButton
690
- compact
691
- minimal
692
- label='Create New Expression'
693
- className='function-selector'
694
- icon='Functions'
695
- tooltip='This field only accepts expressions'
696
- onClick={() => {
697
- setIsTemplate(false);
698
- setTemplateValue(null);
699
- onChange?.(
700
- name,
701
- {
702
- args: [],
703
- },
704
- undefined,
705
- true
706
- );
707
- }}
708
- />
709
- )
710
- ) : null;
1170
+ return (
1171
+ showFunctionsDropdown ?
1172
+ functions.loading ?
1173
+ <ReqoreSkeleton size={rest.size} />
1174
+ : <ReqoreButton
1175
+ compact
1176
+ minimal
1177
+ label='Create New Expression'
1178
+ className='function-selector'
1179
+ icon='Functions'
1180
+ tooltip='This field only accepts expressions'
1181
+ onClick={() => {
1182
+ setIsTemplate(false);
1183
+ setTemplateValue(null);
1184
+ onChange?.(
1185
+ name,
1186
+ {
1187
+ args: [],
1188
+ },
1189
+ undefined,
1190
+ true
1191
+ );
1192
+ }}
1193
+ />
1194
+ : null
1195
+ );
711
1196
  }
712
1197
 
713
1198
  if (hasValueRows || size(menuActions?.items) > 0) {
@@ -728,10 +1213,18 @@ export const TemplateField = memo(
728
1213
  transparent: true,
729
1214
  size: rest.size,
730
1215
  fixed: true,
731
- // Centre the trailing menu in its flex line so it lines up with
732
- // sibling action buttons (reqore alignSelf; replaces a reqraft
733
- // `align-self !important` override of this button).
734
- alignSelf: 'center',
1216
+ /* Centre the trailing menu in its flex line so it lines up
1217
+ with sibling action buttons (reqore alignSelf; replaces a
1218
+ reqraft `align-self !important` override of this button).
1219
+
1220
+ Centring is only right beside a ONE-LINE editor. The
1221
+ expression shell is a toolbar with an editor, a type message
1222
+ and a preview stacked under it, so centring put this menu
1223
+ somewhere down the side of that block — level with nothing,
1224
+ over the editor, and a long way from the Undo it belongs
1225
+ beside. There it goes to the top, which is where the shell's
1226
+ own toolbar is. */
1227
+ alignSelf: effectiveIsFunction ? 'flex-start' : 'center',
735
1228
  label: hasInputAffordance || !hasValueRows ? undefined : 'Set value',
736
1229
  style:
737
1230
  hasInputAffordance ?
@@ -757,11 +1250,10 @@ export const TemplateField = memo(
757
1250
  handler='click'
758
1251
  content={
759
1252
  <ReqoreMenu size={rest.size} maxHeight='400px' style={{ overflow: 'auto' }}>
760
- {showFunctionsDropdown ? (
761
- functions.loading ? (
1253
+ {showFunctionsDropdown ?
1254
+ functions.loading ?
762
1255
  <ReqoreSkeleton size={rest.size} />
763
- ) : (
764
- <ReqoreButton
1256
+ : <ReqoreButton
765
1257
  compact
766
1258
  transparent
767
1259
  label='Use Expression'
@@ -770,10 +1262,10 @@ export const TemplateField = memo(
770
1262
  tooltip='Run a function on this value'
771
1263
  onClick={handleSelectFunctionChange}
772
1264
  />
773
- )
774
- ) : null}
775
1265
 
776
- {showTemplatesButton ? (
1266
+ : null}
1267
+
1268
+ {showTemplatesButton ?
777
1269
  <ReqoreButton
778
1270
  transparent
779
1271
  icon='MoneyDollarCircleLine'
@@ -786,19 +1278,19 @@ export const TemplateField = memo(
786
1278
  {' '}
787
1279
  Use Template{' '}
788
1280
  </ReqoreButton>
789
- ) : null}
1281
+ : null}
790
1282
 
791
- {size(menuActions?.items) > 0 ? (
1283
+ {size(menuActions?.items) > 0 ?
792
1284
  <MenuActionsSection actions={menuActions} size={rest.size} />
793
- ) : null}
1285
+ : null}
794
1286
 
795
- {size(menuItems) > 0 ? (
1287
+ {size(menuItems) > 0 ?
796
1288
  <CustomMenuItems
797
1289
  items={menuItems}
798
1290
  setIsTemplate={setIsTemplate}
799
1291
  setTemplateValue={setTemplateValue}
800
1292
  />
801
- ) : null}
1293
+ : null}
802
1294
  </ReqoreMenu>
803
1295
  }
804
1296
  />
@@ -822,7 +1314,10 @@ export const TemplateField = memo(
822
1314
  menuItems,
823
1315
  menuActions,
824
1316
  internalIsFunction,
1317
+ effectiveIsFunction,
825
1318
  hasInputAffordance,
1319
+ // the row decides whether this field draws a menu at all
1320
+ rowMenu,
826
1321
  ]);
827
1322
 
828
1323
  // When the type is a list, and it has an element type - that element type is different
@@ -831,12 +1326,12 @@ export const TemplateField = memo(
831
1326
  // This is a special case only for lists with element types
832
1327
  const componentTemplates = useMemo(
833
1328
  () =>
834
- type === 'list' && (rest.ui_element_type || rest.element_type)
835
- ? templates
836
- : {
837
- ...filteredTemplates,
838
- ...TemplatesListProps,
839
- },
1329
+ type === 'list' && (rest.ui_element_type || rest.element_type) ?
1330
+ templates
1331
+ : {
1332
+ ...filteredTemplates,
1333
+ ...TemplatesListProps,
1334
+ },
840
1335
  [JSON.stringify(filteredTemplates), rest.ui_element_type, rest.element_type, type]
841
1336
  );
842
1337
 
@@ -854,9 +1349,14 @@ export const TemplateField = memo(
854
1349
  is_expression: true,
855
1350
  value,
856
1351
  }}
1352
+ // The host's per-ui_type editors reach this field through the
1353
+ // rest-spread; the expression shell needs them explicitly or the
1354
+ // builder's operands render "Unknown type!" for any consumer
1355
+ // ui_type (an assertion's `test-reference` Value, for one).
1356
+ componentOverrides={componentOverrides}
857
1357
  localTemplates={templates}
858
1358
  type={type as string}
859
- returnType={(returnType || type) as any}
1359
+ returnType={(returnType || expressionDataType) as any}
860
1360
  onChange={handleExpressionChange}
861
1361
  readOnly={rest.readOnly || rest.disabled}
862
1362
  expressions={rest.expressions}
@@ -864,9 +1364,29 @@ export const TemplateField = memo(
864
1364
  serverHandled={rest.server_expression_handling}
865
1365
  extraActions={extraActions}
866
1366
  size={rest.size}
1367
+ // An author who typed the expression as text is already
1368
+ // writing in that language — dropping them into the visual
1369
+ // builder would make them find their own sentence again.
1370
+ defaultMode={expressionFromText !== null ? 'text' : 'visual'}
867
1371
  reorder={reorder}
868
1372
  />
869
1373
  </ReqoreErrorBoundary>
1374
+ {expressionFromText !== null ?
1375
+ <ReqoreButton
1376
+ fixed
1377
+ compact
1378
+ minimal
1379
+ icon='ArrowGoBackLine'
1380
+ // No `intent` with `minimal`: a Reqore intent is a FILL, and
1381
+ // `muted` as TEXT is 1.3:1 on the app surface.
1382
+ size={rest.size}
1383
+ className='dpql-detected-undo'
1384
+ tooltip={`Keep "${expressionFromText}" as plain text instead`}
1385
+ onClick={undoExpressionFromText}
1386
+ >
1387
+ Undo
1388
+ </ReqoreButton>
1389
+ : null}
870
1390
  {renderControls()}
871
1391
  </ReqoreControlGroup>
872
1392
  );
@@ -880,10 +1400,11 @@ export const TemplateField = memo(
880
1400
  is_expression: true,
881
1401
  value,
882
1402
  }}
1403
+ componentOverrides={componentOverrides}
883
1404
  localTemplates={templates}
884
1405
  level={level}
885
1406
  type={type as string}
886
- returnType={(returnType || type) as any}
1407
+ returnType={(returnType || expressionDataType) as any}
887
1408
  onChange={handleExpressionChange}
888
1409
  readOnly={rest.readOnly || rest.disabled}
889
1410
  expressions={rest.expressions}
@@ -907,7 +1428,15 @@ export const TemplateField = memo(
907
1428
  return <ReadOnlyTemplateTag value={templateValue} templates={templates} size={rest.size} />;
908
1429
  }
909
1430
 
910
- return <Comp value={value} onChange={onChange} name={name} {...rest} />;
1431
+ return (
1432
+ <Comp
1433
+ value={value}
1434
+ onChange={onChange}
1435
+ name={name}
1436
+ {...rest}
1437
+ componentOverrides={componentOverrides}
1438
+ />
1439
+ );
911
1440
  }
912
1441
 
913
1442
  return (
@@ -919,7 +1448,7 @@ export const TemplateField = memo(
919
1448
  stack={false}
920
1449
  verticalAlign='flex-start'
921
1450
  >
922
- {!isTemplate && allowCustomValues ? (
1451
+ {!isTemplate && allowCustomValues ?
923
1452
  <Component
924
1453
  value={value}
925
1454
  allowTemplates={allowTemplates}
@@ -927,14 +1456,15 @@ export const TemplateField = memo(
927
1456
  name={name}
928
1457
  level={level}
929
1458
  {...rest}
1459
+ componentOverrides={componentOverrides}
930
1460
  {...componentTypeProp}
931
1461
  aria-label={fieldAriaLabel}
932
1462
  className={`${className} template-selector`}
933
1463
  templates={componentTemplates}
934
1464
  />
935
- ) : null}
1465
+ : null}
936
1466
 
937
- {isTemplate && templateSupportsCustomValues && !templateValueIsBracedToken ? (
1467
+ {isTemplate && templateSupportsCustomValues && !templateValueIsBracedToken ?
938
1468
  <LongStringField
939
1469
  className='template-selector'
940
1470
  type='string'
@@ -949,10 +1479,35 @@ export const TemplateField = memo(
949
1479
  {...rest}
950
1480
  aria-label={fieldAriaLabel}
951
1481
  />
952
- ) : null}
1482
+ : null}
953
1483
 
954
- {showTemplatesDropdown ||
955
- (isTemplate && templateSupportsCustomValues && templateValueIsBracedToken) ? (
1484
+ {dpqlOffer ?
1485
+ <ReqoreControlGroup fixed stack size={rest.size}>
1486
+ <ReqoreButton
1487
+ compact
1488
+ icon='Functions'
1489
+ intent='info'
1490
+ className='dpql-detected-offer'
1491
+ tooltip={`"${dpqlOffer.text}" reads as an expression. It is also valid text here, so nothing has been changed — use it as an expression?`}
1492
+ onClick={acceptDpqlOffer}
1493
+ >
1494
+ Use as expression
1495
+ </ReqoreButton>
1496
+ <ReqoreButton
1497
+ compact
1498
+ icon='CloseLine'
1499
+ intent='info'
1500
+ className='dpql-detected-dismiss'
1501
+ tooltip='Keep it as plain text'
1502
+ onClick={declineDpqlOffer}
1503
+ />
1504
+ </ReqoreControlGroup>
1505
+ : null}
1506
+
1507
+ {(
1508
+ showTemplatesDropdown ||
1509
+ (isTemplate && templateSupportsCustomValues && templateValueIsBracedToken)
1510
+ ) ?
956
1511
  <TemplateDropdownSelector
957
1512
  allowCustomValues={allowCustomValues}
958
1513
  templates={templates}
@@ -964,10 +1519,27 @@ export const TemplateField = memo(
964
1519
  label={label}
965
1520
  hasOnlyAllowedValues={hasOnlyAllowedValues}
966
1521
  />
967
- ) : null}
1522
+ : null}
968
1523
 
969
1524
  {renderControls()}
970
1525
  </ReqoreControlGroup>
971
1526
  );
972
1527
  }
973
1528
  );
1529
+
1530
+ /**
1531
+ * The row channel belongs to the row's OWN editor: this field. It is read here
1532
+ * and hidden from everything this field renders, so a field nested inside it —
1533
+ * an expression operand, a list item — keeps drawing its own menu instead of
1534
+ * publishing actions into a row menu that cannot say which value they act on.
1535
+ * `undefined` outside a row, where this field draws its own menu too.
1536
+ */
1537
+ export const TemplateField = memo((props: ITemplateFieldProps) => {
1538
+ const rowMenu = useRowMenu();
1539
+
1540
+ return (
1541
+ <RowMenuContext.Provider value={undefined}>
1542
+ <TemplateFieldImpl {...props} rowMenu={rowMenu} />
1543
+ </RowMenuContext.Provider>
1544
+ );
1545
+ });