@qoretechnologies/reqraft 0.10.61 → 0.10.62-pr.137.g86641aa

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 (101) hide show
  1. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  2. package/dist/components/dpqlEditor/DpqlEditor.js +8 -3
  3. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  4. package/dist/components/dpqlEditor/types.d.ts +5 -0
  5. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  6. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  7. package/dist/components/form/engine/CompactRow.js +17 -8
  8. package/dist/components/form/engine/CompactRow.js.map +1 -1
  9. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  10. package/dist/components/form/engine/FormEngine.js +65 -15
  11. package/dist/components/form/engine/FormEngine.js.map +1 -1
  12. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  13. package/dist/components/form/engine/OptionFieldMessages.js +12 -1
  14. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  15. package/dist/components/form/engine/compactRowContext.d.ts +2 -0
  16. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  17. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  18. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  19. package/dist/components/form/engine/readFirst.js +5 -1
  20. package/dist/components/form/engine/readFirst.js.map +1 -1
  21. package/dist/components/form/expressions/ExpressionField.d.ts +21 -1
  22. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  23. package/dist/components/form/expressions/ExpressionField.js +72 -14
  24. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  25. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  26. package/dist/components/form/expressions/builder/index.js +16 -3
  27. package/dist/components/form/expressions/builder/index.js.map +1 -1
  28. package/dist/components/form/expressions/expressionOperands.d.ts +16 -0
  29. package/dist/components/form/expressions/expressionOperands.d.ts.map +1 -0
  30. package/dist/components/form/expressions/expressionOperands.js +97 -0
  31. package/dist/components/form/expressions/expressionOperands.js.map +1 -0
  32. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  33. package/dist/components/form/expressions/mockExpressions.js +15 -0
  34. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  35. package/dist/components/form/expressions/renderExpressionToText.d.ts +2 -0
  36. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  37. package/dist/components/form/expressions/renderExpressionToText.js +24 -5
  38. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  39. package/dist/components/form/expressions/textOfExpression.d.ts +18 -0
  40. package/dist/components/form/expressions/textOfExpression.d.ts.map +1 -0
  41. package/dist/components/form/expressions/textOfExpression.js +37 -0
  42. package/dist/components/form/expressions/textOfExpression.js.map +1 -0
  43. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  44. package/dist/components/form/expressions/useRenderExpression.js +7 -7
  45. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  46. package/dist/components/form/fields/rich-text/RichText.d.ts +8 -1
  47. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  48. package/dist/components/form/fields/rich-text/RichText.js +10 -10
  49. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  50. package/dist/components/form/fields/template/TemplateField.d.ts +6 -0
  51. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  52. package/dist/components/form/fields/template/TemplateField.js +280 -55
  53. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  54. package/dist/components/form/fields/template/writtenAsText.d.ts +57 -0
  55. package/dist/components/form/fields/template/writtenAsText.d.ts.map +1 -0
  56. package/dist/components/form/fields/template/writtenAsText.js +203 -0
  57. package/dist/components/form/fields/template/writtenAsText.js.map +1 -0
  58. package/dist/components/form/index.d.ts +2 -0
  59. package/dist/components/form/index.d.ts.map +1 -1
  60. package/dist/components/form/index.js +7 -1
  61. package/dist/components/form/index.js.map +1 -1
  62. package/dist/components/smartEditor/useLspSession.d.ts +6 -0
  63. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  64. package/dist/components/smartEditor/useLspSession.js +7 -6
  65. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  66. package/dist/helpers/templates.d.ts +18 -0
  67. package/dist/helpers/templates.d.ts.map +1 -1
  68. package/dist/helpers/templates.js +30 -1
  69. package/dist/helpers/templates.js.map +1 -1
  70. package/dist/helpers/validations.d.ts.map +1 -1
  71. package/dist/helpers/validations.js +98 -18
  72. package/dist/helpers/validations.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/components/dpqlEditor/DpqlEditor.tsx +11 -18
  75. package/src/components/dpqlEditor/types.ts +5 -0
  76. package/src/components/form/engine/CompactRow.tsx +31 -10
  77. package/src/components/form/engine/FormEngine.stories.tsx +656 -18
  78. package/src/components/form/engine/FormEngine.tsx +64 -22
  79. package/src/components/form/engine/FormEngineRemote.stories.tsx +5 -0
  80. package/src/components/form/engine/OptionFieldMessages.tsx +12 -1
  81. package/src/components/form/engine/compactRowContext.ts +2 -0
  82. package/src/components/form/engine/readFirst.ts +7 -1
  83. package/src/components/form/expressions/ExpressionField.stories.tsx +57 -9
  84. package/src/components/form/expressions/ExpressionField.tsx +179 -60
  85. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +8 -1
  86. package/src/components/form/expressions/builder/index.tsx +16 -3
  87. package/src/components/form/expressions/dpqlMockLanguage.ts +7 -2
  88. package/src/components/form/expressions/expressionOperands.ts +74 -0
  89. package/src/components/form/expressions/mockExpressions.ts +16 -4
  90. package/src/components/form/expressions/renderExpressionToText.ts +26 -9
  91. package/src/components/form/expressions/textOfExpression.ts +39 -0
  92. package/src/components/form/expressions/useRenderExpression.ts +9 -1
  93. package/src/components/form/fields/number/Number.stories.tsx +8 -7
  94. package/src/components/form/fields/rich-text/RichText.tsx +18 -10
  95. package/src/components/form/fields/template/TemplateField.stories.tsx +486 -18
  96. package/src/components/form/fields/template/TemplateField.tsx +506 -62
  97. package/src/components/form/fields/template/writtenAsText.ts +217 -0
  98. package/src/components/form/index.tsx +6 -0
  99. package/src/components/smartEditor/useLspSession.ts +15 -10
  100. package/src/helpers/templates.ts +35 -0
  101. package/src/helpers/validations.ts +83 -12
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  ReqoreButton,
11
11
  ReqoreControlGroup,
12
+ ReqoreDropdown,
12
13
  ReqoreMessage,
13
14
  ReqoreVerticalSpacer,
14
15
  } from '@qoretechnologies/reqore';
@@ -20,6 +21,7 @@ import { TDpqlFields } from '../../dpqlEditor/types';
20
21
  import { ExpressionBuilder, IExpressionBuilderProps } from './builder';
21
22
  import { DpqlRendering } from './DpqlRendering';
22
23
  import { IExpression, IExpressionSchema, IExpressionValue, TExpressionReorder } from './types';
24
+ import { incompleteExpressionText, serializableExpression } from './textOfExpression';
23
25
  import { useExpressions } from './useExpressions';
24
26
  import { useRenderExpression } from './useRenderExpression';
25
27
  import { isUntypedOptionType } from '../../../helpers/optionUiTypes';
@@ -80,6 +82,26 @@ export interface IExpressionFieldProps {
80
82
  fields?: TDpqlFields;
81
83
  /** Initial editor mode (default `visual`). */
82
84
  defaultMode?: TExpressionMode;
85
+ /**
86
+ * The Text view's text when the value is not an expression yet: a template or a literal the field held
87
+ * when "Use Template / Expression" opened it. Nothing is emitted until it is edited.
88
+ */
89
+ initialText?: string;
90
+ /**
91
+ * The field holds a plain value the text stands for - a template or a literal written in the Text view
92
+ * (stored as itself, not as an expression). The expression is then gone from `value` but the field is not
93
+ * cleared, so the text being written stays.
94
+ */
95
+ heldAsValue?: boolean;
96
+ /**
97
+ * The view a host's own tabs ask for (Value · Expression · Visual, qorus#646). Followed as the field's own
98
+ * Visual / Text toggle is: leaving the Text view parses what is written first, so nothing is lost.
99
+ */
100
+ requestedMode?: TExpressionMode;
101
+ /** The host draws the views as tabs of its own: the field's Visual / Text toggle is not drawn. */
102
+ hideModeToggle?: boolean;
103
+ /** No language server: the Text view cannot work, and the field shows the Visual view instead. */
104
+ onTextUnavailable?: () => void;
83
105
  /**
84
106
  * SEAM (reqraft): the host's per-`ui_type` editors, forwarded to the
85
107
  * builder's operand fields. Without them an operand typed with one of the
@@ -107,6 +129,11 @@ export const ExpressionField = memo(
107
129
  fields,
108
130
  recordType,
109
131
  defaultMode = 'visual',
132
+ initialText,
133
+ heldAsValue,
134
+ requestedMode,
135
+ hideModeToggle,
136
+ onTextUnavailable,
110
137
  size,
111
138
  componentOverrides,
112
139
  reorder,
@@ -124,7 +151,15 @@ export const ExpressionField = memo(
124
151
 
125
152
  // Text mode state. `text` is the DPQL string the editor shows; the AST
126
153
  // (`value`) stays the source of truth, kept in sync via parse-on-edit.
127
- const [text, setText] = useState('');
154
+ const [text, setText] = useState(initialText ?? '');
155
+ /* No language server to parse or write the text: the Text view cannot work, so the field shows the
156
+ Visual view, which needs none, and says why (qorus#646). */
157
+ const [textUnavailable, setTextUnavailable] = useState(false);
158
+ const handleTextUnavailable = useCallback(() => {
159
+ setTextUnavailable(true);
160
+ setMode('visual');
161
+ onTextUnavailable?.();
162
+ }, [onTextUnavailable]);
128
163
  /* The text the current AST was parsed FROM.
129
164
  *
130
165
  * The preview renders the AST, and the AST only moves on a SUCCESSFUL
@@ -173,9 +208,14 @@ export const ExpressionField = memo(
173
208
  * noise the message below is written to avoid.
174
209
  */
175
210
  const readTypeCheck = useCallback(
176
- (result?: { type_compatible?: boolean; auto_coercible?: boolean;
177
- coercion_may_fail?: boolean; inferred_type?: string; target_type?: string;
178
- suggested_fix?: { text: string } }): void => {
211
+ (result?: {
212
+ type_compatible?: boolean;
213
+ auto_coercible?: boolean;
214
+ coercion_may_fail?: boolean;
215
+ inferred_type?: string;
216
+ target_type?: string;
217
+ suggested_fix?: { text: string };
218
+ }): void => {
179
219
  if (!result || result.type_compatible === undefined) {
180
220
  setTypeCheck(null);
181
221
  return;
@@ -245,12 +285,17 @@ export const ExpressionField = memo(
245
285
  hadExpression.current = true;
246
286
  return;
247
287
  }
288
+ // a template or a literal written here is held as itself: the text stands for it, and stays
289
+ if (heldAsValue) {
290
+ hadExpression.current = false;
291
+ return;
292
+ }
248
293
  if (hadExpression.current) {
249
294
  hadExpression.current = false;
250
295
  userTypedRef.current = false;
251
296
  setText('');
252
297
  }
253
- }, [ast]);
298
+ }, [ast, heldAsValue]);
254
299
 
255
300
  // Text mode: parse the DPQL into the AST (debounced).
256
301
  /* The session may not be attached when the debounce fires — the editor
@@ -329,7 +374,20 @@ export const ExpressionField = memo(
329
374
  }
330
375
  if (seededRef.current || userTypedRef.current) return undefined;
331
376
  if (!ast?.exp) return undefined;
332
- const seedAst = ast;
377
+ /* Only what DPQL can write is serialized. An operand whose operation is not
378
+ chosen yet, or that is not filled in, has no text; handed to serialize as
379
+ it was, it came back as the server's (or a stand-in's) rendering of a hash:
380
+ `{args=(null)} > {type=int}`. The Text view shows the expression with a hole
381
+ for each part not filled in instead - `… > …`, as the row's summary does -
382
+ and serialize gets custom Text values as the strings or templates they are. */
383
+ const seedAst = serializableExpression(ast);
384
+ if (!seedAst) {
385
+ const holes = incompleteExpressionText(ast, expressions);
386
+ seededRef.current = true;
387
+ setText(holes);
388
+ setAstText(holes);
389
+ return undefined;
390
+ }
333
391
  let cancelled = false;
334
392
  let timer: ReturnType<typeof setTimeout> | null = null;
335
393
  let tries = 0;
@@ -349,7 +407,23 @@ export const ExpressionField = memo(
349
407
  cancelled = true;
350
408
  if (timer) clearTimeout(timer);
351
409
  };
352
- }, [mode, ast]);
410
+ }, [mode, ast, expressions]);
411
+
412
+ /** The field's templates, its catalogue's entries, for the Text view's picker. */
413
+ const templateItems = useMemo(
414
+ () => (localTemplates?.items ?? []) as NonNullable<IReqoreFormTemplates['items']>,
415
+ [localTemplates]
416
+ );
417
+ /* A template chosen from the picker goes in after the text, as a chip, and is parsed as typed text is:
418
+ the editor keeps no caret the field can reach, so the end of the text is where it is put. */
419
+ const insertTemplate = useCallback(
420
+ (item: { value?: unknown }) => {
421
+ if (typeof item?.value !== 'string' || !item.value) return;
422
+ const current = text.trimEnd();
423
+ handleDpqlChange(current ? `${current} ${item.value}` : item.value);
424
+ },
425
+ [text, handleDpqlChange]
426
+ );
353
427
 
354
428
  // Switch to Text: the seeding effect above serializes the AST once
355
429
  // the editor's session is up.
@@ -370,6 +444,13 @@ export const ExpressionField = memo(
370
444
  setMode('visual');
371
445
  }, [mode, text, onChange, targetType, readTypeCheck]);
372
446
 
447
+ // the host's tabs: followed as the toggle is (see `requestedMode`)
448
+ useEffect(() => {
449
+ if (!requestedMode || requestedMode === mode) return;
450
+ if (requestedMode === 'visual') void enterVisualMode();
451
+ else if (!textUnavailable) enterTextMode();
452
+ }, [requestedMode]);
453
+
373
454
  useEffect(
374
455
  () => () => {
375
456
  if (parseTimer.current) clearTimeout(parseTimer.current);
@@ -394,39 +475,64 @@ export const ExpressionField = memo(
394
475
  className='expression-field'
395
476
  data-preview={mode === 'text' ? previewState : undefined}
396
477
  >
397
- <ReqoreControlGroup gapSize='small' fluid>
398
- <ReqoreButton
399
- icon='NodeTree'
400
- active={mode === 'visual'}
401
- onClick={enterVisualMode}
402
- disabled={readOnly}
403
- size={size as any}
404
- >
405
- Visual
406
- </ReqoreButton>
407
- <ReqoreButton
408
- icon='CodeLine'
409
- active={mode === 'text'}
410
- onClick={enterTextMode}
411
- disabled={readOnly}
412
- size={size as any}
413
- >
414
- Text
415
- </ReqoreButton>
416
- </ReqoreControlGroup>
478
+ {hideModeToggle ? null : (
479
+ <ReqoreControlGroup gapSize='small' fluid>
480
+ <ReqoreButton
481
+ icon='NodeTree'
482
+ active={mode === 'visual'}
483
+ onClick={enterVisualMode}
484
+ disabled={readOnly}
485
+ size={size as any}
486
+ >
487
+ Visual
488
+ </ReqoreButton>
489
+ <ReqoreButton
490
+ icon='CodeLine'
491
+ active={mode === 'text'}
492
+ onClick={enterTextMode}
493
+ disabled={readOnly || textUnavailable}
494
+ tooltip={
495
+ textUnavailable ? 'The expression language server is not available' : undefined
496
+ }
497
+ size={size as any}
498
+ >
499
+ Text
500
+ </ReqoreButton>
501
+ </ReqoreControlGroup>
502
+ )}
417
503
 
418
- {mode === 'text' ? (
504
+ {mode === 'text' ?
419
505
  <>
420
- <DpqlEditor
421
- ref={dpqlRef}
422
- value={text}
423
- onChange={handleDpqlChange}
424
- provider={provider}
425
- recordType={recordType}
426
- fields={fields}
427
- readOnly={readOnly}
428
- height='48px'
429
- />
506
+ <ReqoreControlGroup gapSize='small' fluid verticalAlign='flex-start'>
507
+ <DpqlEditor
508
+ ref={dpqlRef}
509
+ value={text}
510
+ onChange={handleDpqlChange}
511
+ provider={provider}
512
+ recordType={recordType}
513
+ fields={fields}
514
+ // the field's own templates name the chips as its catalogue names them
515
+ templates={localTemplates}
516
+ readOnly={readOnly}
517
+ height='48px'
518
+ onUnavailable={handleTextUnavailable}
519
+ />
520
+ {/* The field's templates and fields, as its catalogue names them, inserted as chips - next to
521
+ the server's `$` completion, which offers every context but not these names. */}
522
+ {templateItems.length && !readOnly ?
523
+ <ReqoreDropdown
524
+ className='expression-text-template-picker'
525
+ icon='MoneyDollarCircleLine'
526
+ aria-label='Insert a template'
527
+ tooltip='Insert a template'
528
+ size={size as any}
529
+ filterable
530
+ fixed
531
+ items={templateItems}
532
+ onItemSelect={insertTemplate}
533
+ />
534
+ : null}
535
+ </ReqoreControlGroup>
430
536
  {/* What the server said about the result's type, when the field
431
537
  declares one to check against.
432
538
 
@@ -449,12 +555,12 @@ export const ExpressionField = memo(
449
555
  {typeCheck.mayFail ?
450
556
  ' That conversion is attempted rather than guaranteed, so the value itself is checked when it runs.'
451
557
  : ''}
452
- {typeCheck.fix ? (
558
+ {typeCheck.fix ?
453
559
  <>
454
560
  <ReqoreVerticalSpacer height={6} />
455
561
  <DpqlRendering text={typeCheck.fix} data-testid='expression-type-fix' />
456
562
  </>
457
- ) : null}
563
+ : null}
458
564
  </ReqoreMessage>
459
565
  )}
460
566
 
@@ -471,30 +577,43 @@ export const ExpressionField = memo(
471
577
 
472
578
  An empty query has nothing to render either, so the box appears
473
579
  once there is a result rather than holding a placeholder. */}
474
- {previewState === 'shown' ? (
580
+ {previewState === 'shown' ?
475
581
  <ReqoreMessage intent='info' size='small' title='Preview' flat opaque={false}>
476
582
  <DpqlRendering text={preview} data-testid='expression-preview' />
477
583
  </ReqoreMessage>
478
- ) : null}
584
+ : null}
479
585
  </>
480
- ) : (
481
- <ExpressionBuilder
482
- value={value}
483
- onChange={(v, remove) => onChange(v, remove)}
484
- expressions={expressions}
485
- // Default to `auto` when the field declares no return type, so a
486
- // valid expression renders `muted` rather than `danger` (the
487
- // builder's intent requires a truthy returnType).
488
- returnType={(returnType ?? type ?? 'auto') as any}
489
- readOnly={readOnly}
490
- localTemplates={localTemplates ?? { items: [] }}
491
- serverHandled={serverHandled}
492
- componentOverrides={componentOverrides}
493
- extraActions={extraActions}
494
- size={size}
495
- reorder={reorder}
496
- />
497
- )}
586
+ : <>
587
+ {textUnavailable ?
588
+ <ReqoreMessage
589
+ intent='muted'
590
+ size='small'
591
+ flat
592
+ opaque={false}
593
+ className='expression-text-unavailable'
594
+ >
595
+ The Text view needs the expression language server, which is not available: the
596
+ expression is built here instead.
597
+ </ReqoreMessage>
598
+ : null}
599
+ <ExpressionBuilder
600
+ value={value}
601
+ onChange={(v, remove) => onChange(v, remove)}
602
+ expressions={expressions}
603
+ // Default to `auto` when the field declares no return type, so a
604
+ // valid expression renders `muted` rather than `danger` (the
605
+ // builder's intent requires a truthy returnType).
606
+ returnType={(returnType ?? type ?? 'auto') as any}
607
+ readOnly={readOnly}
608
+ localTemplates={localTemplates ?? { items: [] }}
609
+ serverHandled={serverHandled}
610
+ componentOverrides={componentOverrides}
611
+ extraActions={extraActions}
612
+ size={size}
613
+ reorder={reorder}
614
+ />
615
+ </>
616
+ }
498
617
  </ReqoreControlGroup>
499
618
  );
500
619
  }
@@ -200,12 +200,19 @@ export const WithSimpleValue: Story = {
200
200
  docs: {
201
201
  description: {
202
202
  story:
203
- 'Renders the ExpressionBuilder holding a single "contains" expression with three arguments — the card shows the operation and each operand slot.',
203
+ 'Renders the ExpressionBuilder holding a single "contains" expression with three arguments — the card shows the operation and each operand slot. Each text value is 150px wide, the one holding a template as well as the one holding text: in a row with room, a text field is no narrower than that.',
204
204
  },
205
205
  },
206
206
  },
207
207
  play: async () => {
208
208
  await waitFor(() => expect(expressionCount()).toBe(1), { timeout: 10000 });
209
+ await waitFor(() =>
210
+ expect(
211
+ [...document.querySelectorAll<HTMLElement>('.expression-arg [data-slate-editor]')].map((editor) =>
212
+ Math.round((editor.closest('.reqore-panel') as HTMLElement).getBoundingClientRect().width)
213
+ )
214
+ ).toEqual([150, 150])
215
+ );
209
216
  },
210
217
  };
211
218
 
@@ -32,6 +32,7 @@ import {
32
32
  isTextOrDateType,
33
33
  } from '../../../../helpers/expressions';
34
34
  import { addMissingExpressionArgs } from '../argumentPresence';
35
+ import { isUntypedOptionType } from '../../../../helpers/optionUiTypes';
35
36
  import { findTemplate } from '../../../../helpers/templates';
36
37
  import { validateField, validateFieldWithResult } from '../../../../helpers/validations';
37
38
  import { IQorusTypeObject, useQorusTypes } from '../../../../hooks/useQorusTypes';
@@ -520,9 +521,16 @@ export const Expression = ({
520
521
  }, [onValueChange, path]);
521
522
 
522
523
  const firstArgSchema = selectedExpression?.args[0];
524
+ /* An empty untyped operand is of the type the catalogue declares for it, as the other operands are:
525
+ falling back to the builder's own type gave the first value of a condition (the builder's `bool`)
526
+ or of an untyped builder (`context`) a type it is not, which rendered as "Unknown type!" once an
527
+ untyped operand was written as text. */
523
528
  const firstParamType = firstArgument?.is_expression
524
529
  ? firstArgSchema?.ui_type
525
- : firstArgument?.type || type || 'context';
530
+ : firstArgument?.type ||
531
+ (isUntypedOptionType(firstArgSchema?.ui_type) ? firstArgSchema?.ui_type : undefined) ||
532
+ type ||
533
+ 'context';
526
534
  let restOfArgs = selectedExpression?.args.slice(1);
527
535
  const argCount = size(value.value?.args);
528
536
  const canReorderArgs =
@@ -1129,7 +1137,10 @@ export const Expression = ({
1129
1137
  allowCustomValues={
1130
1138
  !!firstArgument?.allowed_values ||
1131
1139
  !!firstArgument?.element_allowed_values ||
1132
- (!!firstArgument?.type && !serverExpression)
1140
+ // an untyped operand is written as text from the start, as a typed one is once
1141
+ // it has a type: a template chosen into it is a chip, with text around it
1142
+ ((!!firstArgument?.type || isUntypedOptionType(firstArgSchema?.ui_type)) &&
1143
+ !serverExpression)
1133
1144
  }
1134
1145
  filterTemplatesFunc={(templates) => {
1135
1146
  // PORT NOTE: `?.` on `items` added — with no templates at all
@@ -1242,7 +1253,9 @@ export const Expression = ({
1242
1253
  !!arg.allowed_values ||
1243
1254
  !!arg.element_allowed_values ||
1244
1255
  !!rest[index]?.type ||
1245
- !!arg.default_value
1256
+ !!arg.default_value ||
1257
+ // an untyped operand is written as text from the start (see the first operand)
1258
+ isUntypedOptionType(arg.ui_type)
1246
1259
  }
1247
1260
  onChange={((_name, value, type, isFunction) => {
1248
1261
  updateArg(value, index + 1, isFunction ? undefined : type, isFunction);
@@ -391,8 +391,13 @@ export const mockParseDpql = (text: string, target?: string): IDpqlMockParseResu
391
391
  const tokens = tokenize(trimmed);
392
392
  const node = parseTokens(tokens, trimmed.length + 1);
393
393
  // A lone value is wrapped in `value(...)`, which is how the server tells a
394
- // literal apart from an expression.
395
- const root: IDpqlMockNode = node.exp ? node : { exp: 'value', args: [node] };
394
+ // literal apart from an expression; a lone template reference in `template(...)`,
395
+ // as the server's parser wraps one (Qore DpqlParser: DP_EXPR_TEMPLATE).
396
+ const isTemplate = !!(node.value as { tmpl_context?: unknown } | undefined)?.tmpl_context;
397
+ const root: IDpqlMockNode =
398
+ node.exp ? node
399
+ : isTemplate ? { exp: 'template', args: [node] }
400
+ : { exp: 'value', args: [node] };
396
401
  const inferred = inferDpqlMockType(root);
397
402
  return {
398
403
  success: true,
@@ -0,0 +1,74 @@
1
+ // Copyright 2026 Qore Technologies, s.r.o.
2
+ // The operands of an expression as the Visual view stores them, read as what they hold.
3
+ //
4
+ // The Visual view stores operands in the shapes its editors produce, and two of
5
+ // them are not values at all:
6
+ //
7
+ // - an operand switched to "Use Expression" whose operation is not chosen yet
8
+ // (`{ is_expression: true, value: { args } }`, no `exp`), and an operand not
9
+ // filled in (`{}`, `{ type }`): there is nothing in them yet;
10
+ // - a custom Text value, which is a rich-text document (a list of Slate nodes),
11
+ // not a string.
12
+ //
13
+ // Everything that writes an expression as text reads its operands here - the
14
+ // Text view's `dpql/serialize`, the read-first row summary, and an application's
15
+ // own summaries - so none of them prints a hash or `[object Object]` for them.
16
+ // Pure: no transport/socket import.
17
+ import { richtextToSegments, richtextToString } from '../../../helpers/common';
18
+ import { IExpression, IExpressionValue } from './types';
19
+
20
+ const isObject = (value: unknown): value is Record<string, any> =>
21
+ !!value && typeof value === 'object' && !Array.isArray(value);
22
+
23
+ /** A rich-text value as the operand it holds, or `undefined` when it is empty. */
24
+ const richtextOperand = (value: unknown): IExpression | undefined => {
25
+ const segments = richtextToSegments(value as never).filter(
26
+ (segment) => segment.kind === 'tag' || segment.text.trim()
27
+ );
28
+ if (!segments.length) return undefined;
29
+ // a single template is that template, as a template picked from the list is
30
+ if (segments.length === 1 && segments[0].kind === 'tag') {
31
+ return { type: 'auto', value: segments[0].value };
32
+ }
33
+ return { type: 'string', value: richtextToString(value as never) };
34
+ };
35
+
36
+ /**
37
+ * One operand as what it holds: a literal, a `{ type, value }`, or a nested
38
+ * expression whose own operands are read the same way; `undefined` when it is
39
+ * not filled in yet (an operation not chosen, a value not given).
40
+ *
41
+ * A nested expression whose operation is chosen but whose operands are not all
42
+ * filled in is kept, with its missing operands as `undefined`, so a summary can
43
+ * write what is there.
44
+ */
45
+ export const expressionOperand = (arg: unknown): unknown => {
46
+ if (arg === undefined || arg === null) return undefined;
47
+ if (!isObject(arg)) return arg;
48
+ if (arg.is_expression) {
49
+ if (!isObject(arg.value) || !arg.value.exp) return undefined;
50
+ return { ...arg, value: expressionWithOperands(arg.value as IExpressionValue) };
51
+ }
52
+ if ('exp' in arg) return arg.exp ? expressionWithOperands(arg as IExpressionValue) : undefined;
53
+ if (!('value' in arg) || arg.value === undefined) return undefined;
54
+ if (arg.type === 'richtext') return richtextOperand(arg.value);
55
+ return arg;
56
+ };
57
+
58
+ /** The expression with each operand read as what it holds (`undefined` where none is given yet). */
59
+ export const expressionWithOperands = (value: IExpressionValue): IExpressionValue => ({
60
+ ...value,
61
+ args: (value.args ?? []).map(expressionOperand) as IExpression[],
62
+ });
63
+
64
+ /** Whether every part of the expression is filled in: an operation, and each of its operands. */
65
+ export const isExpressionComplete = (value: IExpressionValue | undefined): boolean => {
66
+ if (!value?.exp) return false;
67
+ return (value.args ?? []).every((arg) => {
68
+ const operand = expressionOperand(arg);
69
+ if (operand === undefined) return false;
70
+ if (isObject(operand) && operand.is_expression) return isExpressionComplete(operand.value as IExpressionValue);
71
+ if (isObject(operand) && 'exp' in operand) return isExpressionComplete(operand as IExpressionValue);
72
+ return true;
73
+ });
74
+ };
@@ -8,10 +8,7 @@ import { IExpressionSchema } from './types';
8
8
 
9
9
  // String comparators share this arg shape: a richtext value (with the
10
10
  // operator word as `label_after`), a richtext operand, and an Ignore Case flag.
11
- const stringArgs = (
12
- operandLabel: string,
13
- operatorWord: string
14
- ): IExpressionSchema['args'] => [
11
+ const stringArgs = (operandLabel: string, operatorWord: string): IExpressionSchema['args'] => [
15
12
  {
16
13
  name: 'softstring',
17
14
  display_name: 'String Value',
@@ -36,6 +33,21 @@ const comparatorArgs = (operatorWord: string): IExpressionSchema['args'] => [
36
33
  ];
37
34
 
38
35
  export const mockExpressions: IExpressionSchema[] = [
36
+ // As the server defines it (qorus Classes/QorusExpressions.qc QO_VALUE): what a value written alone parses
37
+ // into, so a value the Visual view starts from is shown as itself.
38
+ {
39
+ name: 'value',
40
+ display_name: 'Value',
41
+ short_desc: 'Evaluates and returns a field reference value',
42
+ desc: 'Evaluates the given field reference and returns its value; this allows field references to be used as standalone expressions',
43
+ symbol: '',
44
+ type: 1,
45
+ subtype: 1,
46
+ return_type: 'any',
47
+ ui_return_type: 'any',
48
+ varargs: false,
49
+ args: [{ name: 'any', display_name: 'Value', ui_type: 'any', required: true }],
50
+ },
39
51
  {
40
52
  name: '==',
41
53
  display_name: 'Logical Equals',
@@ -4,8 +4,12 @@
4
4
  // can wait uses it: Explain and the Text view's Preview wait for the server's
5
5
  // `dpql/renderExpression` (see `useRenderExpression`). Pure: no
6
6
  // transport/socket import, so it stays out of the LSP/nanoid dependency graph.
7
+ import { expressionOperand } from './expressionOperands';
7
8
  import { IExpression, IExpressionSchema, IExpressionValue } from './types';
8
9
 
10
+ /** How a part not filled in yet is written: an operation not chosen, a value not given. */
11
+ export const EXPRESSION_HOLE = '…';
12
+
9
13
  /** A symbol made only of non-word characters renders infix (`a == b`). */
10
14
  const isOperatorSymbol = (symbol?: string): boolean =>
11
15
  !!symbol && /^[^\w\s]+$/.test(symbol);
@@ -109,15 +113,28 @@ const renderInContext = (
109
113
  // parentheses and commas already delimit its arguments.
110
114
  const isInfix = isLogicalGroup || (args.length === 2 && isOperatorSymbol(symbol));
111
115
 
112
- const parts = args.map((arg: IExpression, argn: number) =>
113
- arg?.is_expression && arg.value?.exp
114
- ? renderInContext(
115
- arg.value,
116
- expressions,
117
- isInfix ? argumentContext(value.exp as string, argn) : PREC_LOOSEST
118
- )
119
- : renderLiteral(arg?.value)
120
- );
116
+ /* Each operand as what it holds (expressionOperands): a custom Text value is the
117
+ template or the words in it, and one not filled in yet - an operation not chosen,
118
+ a value not given - is a hole, not `null` and not the editor's rich-text document. */
119
+ const parts = args.map((raw: IExpression, argn: number) => {
120
+ const arg = expressionOperand(raw) as IExpression | undefined;
121
+ if (arg === undefined) return EXPRESSION_HOLE;
122
+ if (arg?.is_expression && arg.value?.exp) {
123
+ return renderInContext(
124
+ arg.value,
125
+ expressions,
126
+ isInfix ? argumentContext(value.exp as string, argn) : PREC_LOOSEST
127
+ );
128
+ }
129
+ if (arg && typeof arg === 'object' && 'exp' in arg) {
130
+ return renderInContext(
131
+ arg as IExpressionValue,
132
+ expressions,
133
+ isInfix ? argumentContext(value.exp as string, argn) : PREC_LOOSEST
134
+ );
135
+ }
136
+ return renderLiteral(arg && typeof arg === 'object' && 'value' in arg ? arg.value : arg);
137
+ });
121
138
 
122
139
  if (!isInfix) {
123
140
  return `${symbol}(${parts.join(', ')})`;
@@ -0,0 +1,39 @@
1
+ // Copyright 2026 Qore Technologies, s.r.o.
2
+ // What the Text view is given for an expression built in the Visual view.
3
+ //
4
+ // `dpql/serialize` was handed the AST as the Visual view stores it, and wrote what
5
+ // it could of operands that are not DPQL (see expressionOperands): the server
6
+ // `{args=(null)} > {type=int}`, a stand-in `undefined([object Object]) > [object Object]`.
7
+ // The Text view serializes only an expression that is filled in, with its operands
8
+ // read as what they hold, and shows one that is not with a hole for each missing part.
9
+ import { expressionOperand, expressionWithOperands, isExpressionComplete } from './expressionOperands';
10
+ import { EXPRESSION_HOLE, renderExpressionToText } from './renderExpressionToText';
11
+ import { IExpressionSchema, IExpressionValue } from './types';
12
+
13
+ export { EXPRESSION_HOLE };
14
+
15
+ /**
16
+ * The expression as `dpql/serialize` can write it, or `undefined` when part of it
17
+ * is not filled in yet (an operation not chosen, an operand left empty).
18
+ */
19
+ export const serializableExpression = (
20
+ value: IExpressionValue | undefined
21
+ ): IExpressionValue | undefined => (isExpressionComplete(value) ? expressionWithOperands(value!) : undefined);
22
+
23
+ /**
24
+ * An expression that is not filled in yet, as text: what is there, and a
25
+ * {@link EXPRESSION_HOLE} for each part that is not - `… > …` for a comparison
26
+ * whose values are not chosen, as the read-first row writes it. Not DPQL; the
27
+ * Text view shows it until the author writes the rest.
28
+ */
29
+ export const incompleteExpressionText = (
30
+ value: IExpressionValue | undefined,
31
+ expressions: IExpressionSchema[] = []
32
+ ): string =>
33
+ value?.exp
34
+ ? renderExpressionToText(value, expressions)
35
+ // a field of the record, as DPQL names it
36
+ .replace(/"\$record:\{([^}]+)\}"/g, '@$1')
37
+ : EXPRESSION_HOLE;
38
+
39
+ export { expressionOperand };
@@ -20,6 +20,7 @@ import { nanoid } from 'nanoid';
20
20
  import { useCallback, useEffect, useRef } from 'react';
21
21
  import { ReqraftLspClient } from '../../../utils/lspClient';
22
22
  import { IExpressionValue } from './types';
23
+ import { expressionWithOperands, isExpressionComplete } from './expressionOperands';
23
24
 
24
25
  /* The synchronous summary a collapsed row prints — see `readFirst.ts`. It is
25
26
  not a stand-in for this rendering and is never shown in its place. */
@@ -137,7 +138,14 @@ export const useRenderExpression = (): IUseRenderExpressionResult => {
137
138
  pendingRef.current = null;
138
139
  if (!job) return;
139
140
  lastRunRef.current = Date.now();
140
- const outcome = job.value?.exp ? await renderViaServer(job.value) : null;
141
+ /* Only what can be rendered is asked for, with its operands read as what they
142
+ hold (expressionOperands) - as the Text view's serialize is. An expression
143
+ with a part not filled in yet (an operation not chosen, a value not given)
144
+ has no rendering; sent as it was, the stories' stand-in server threw on it
145
+ and the real one writes its operands as hashes. */
146
+ const outcome = isExpressionComplete(job.value)
147
+ ? await renderViaServer(expressionWithOperands(job.value))
148
+ : null;
141
149
  job.resolvers.forEach((r) => r(outcome));
142
150
  };
143
151