@qoretechnologies/reqraft 0.10.58-pr.129.gdc37207 → 0.10.58

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 (66) hide show
  1. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  2. package/dist/components/form/engine/FormEngine.js +36 -54
  3. package/dist/components/form/engine/FormEngine.js.map +1 -1
  4. package/dist/components/form/engine/readFirst.d.ts +16 -20
  5. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  6. package/dist/components/form/engine/readFirst.js +16 -49
  7. package/dist/components/form/engine/readFirst.js.map +1 -1
  8. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  9. package/dist/components/form/expressions/builder/index.js +29 -14
  10. package/dist/components/form/expressions/builder/index.js.map +1 -1
  11. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  12. package/dist/components/form/fields/auto/AutoFormField.js +4 -1
  13. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  14. package/dist/components/form/fields/emittedValues.d.ts +5 -0
  15. package/dist/components/form/fields/emittedValues.d.ts.map +1 -1
  16. package/dist/components/form/fields/emittedValues.js +8 -0
  17. package/dist/components/form/fields/emittedValues.js.map +1 -1
  18. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  19. package/dist/components/form/fields/template/TemplateField.js +3 -1
  20. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  21. package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
  22. package/dist/components/smartEditor/SmartEditor.js +27 -4
  23. package/dist/components/smartEditor/SmartEditor.js.map +1 -1
  24. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  25. package/dist/components/smartEditor/useLspSession.js +8 -0
  26. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  27. package/dist/helpers/allowedValues.d.ts +71 -0
  28. package/dist/helpers/allowedValues.d.ts.map +1 -0
  29. package/dist/helpers/allowedValues.js +183 -0
  30. package/dist/helpers/allowedValues.js.map +1 -0
  31. package/dist/helpers/common.d.ts +7 -0
  32. package/dist/helpers/common.d.ts.map +1 -1
  33. package/dist/helpers/common.js +16 -1
  34. package/dist/helpers/common.js.map +1 -1
  35. package/dist/helpers/expressions.d.ts +13 -0
  36. package/dist/helpers/expressions.d.ts.map +1 -1
  37. package/dist/helpers/expressions.js +23 -1
  38. package/dist/helpers/expressions.js.map +1 -1
  39. package/dist/helpers/validations.d.ts.map +1 -1
  40. package/dist/helpers/validations.js +14 -0
  41. package/dist/helpers/validations.js.map +1 -1
  42. package/dist/hooks/useCoarsePointer.d.ts +9 -0
  43. package/dist/hooks/useCoarsePointer.d.ts.map +1 -0
  44. package/dist/hooks/useCoarsePointer.js +29 -0
  45. package/dist/hooks/useCoarsePointer.js.map +1 -0
  46. package/dist/utils/fetch.d.ts +4 -0
  47. package/dist/utils/fetch.d.ts.map +1 -1
  48. package/dist/utils/fetch.js +6 -1
  49. package/dist/utils/fetch.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/components/form/engine/FormEngine.stories.tsx +71 -7
  52. package/src/components/form/engine/FormEngine.tsx +32 -49
  53. package/src/components/form/engine/readFirst.ts +19 -50
  54. package/src/components/form/expressions/builder/index.tsx +21 -3
  55. package/src/components/form/fields/auto/AutoFormField.tsx +4 -1
  56. package/src/components/form/fields/emittedValues.ts +8 -0
  57. package/src/components/form/fields/template/TemplateField.tsx +4 -1
  58. package/src/components/smartEditor/SmartEditor.tsx +28 -4
  59. package/src/components/smartEditor/useLspSession.ts +8 -0
  60. package/src/components/supportTicket/ReferencePicker.stories.tsx +16 -2
  61. package/src/helpers/allowedValues.ts +216 -0
  62. package/src/helpers/common.ts +14 -0
  63. package/src/helpers/expressions.ts +25 -0
  64. package/src/helpers/validations.ts +16 -0
  65. package/src/hooks/useCoarsePointer.ts +30 -0
  66. package/src/utils/fetch.ts +10 -1
@@ -11,6 +11,7 @@ import { IExpressionValue } from '../expressions/types';
11
11
  import yaml from 'js-yaml';
12
12
  import { formatTimeoutValue, richtextToString } from '../../../helpers/common';
13
13
  import { firstDeclaredType } from '../../../helpers/optionUiTypes';
14
+ import { findAllowedValueOption } from '../../../helpers/allowedValues';
14
15
 
15
16
  /** "N noun" with naive pluralisation ("1 item", "2 items"). */
16
17
  const pluralize = (count: number, noun: string): string =>
@@ -244,65 +245,33 @@ export const isCompactBooleanOption = (schema: TQorusFormFieldSchema | undefined
244
245
  return type === 'bool' || type === 'boolean';
245
246
  };
246
247
 
248
+ /**
249
+ * Whether a change completes a compact row, so its editor can close.
250
+ *
251
+ * Only a VALUE does: Yes or No, or one of a fixed list. An expression is
252
+ * built one step at a time - an operation, then each of its arguments - and
253
+ * every step is a change; closing the editor on the first one left a bool
254
+ * field's condition at `null > null`, with no way to fill in its arguments
255
+ * short of opening the row again for each of them.
256
+ *
257
+ * @param isExpression the row now holds an expression (`is_expression`)
258
+ */
247
259
  export const shouldAutoCollapseCompactOption = (
248
260
  schema: TQorusFormFieldSchema | undefined,
249
- value: unknown
261
+ value: unknown,
262
+ isExpression?: boolean
250
263
  ): boolean =>
264
+ !isExpression &&
251
265
  !isOptionValueEmpty(value) &&
252
266
  (isCompactBooleanOption(schema) || isFixedCompactAllowedValueOption(schema));
253
267
 
254
268
  /**
255
269
  * Prefer the matching allowed_values entry's display_name (fallback `name`)
256
- * over the raw stored value.
257
- *
258
- * A LIST carries its options under `element_allowed_values` — they constrain
259
- * each element, not the list itself — so a multi-select that was missing here
260
- * printed what it stores rather than what you picked: `orders, batch` for
261
- * fields whose picker reads "Orders, Batch". The gap shows worst exactly where
262
- * allowed values earn their keep, since the stored form is often not readable
263
- * at all (a permission code, `PO_REQUIRE_TYPES`, an app-specific id).
264
- *
265
- * Exported (as `findAllowedValueOption`) because the form engine's value
266
- * validation has to answer the SAME question — "is this stored value one of the
267
- * declared choices?" — and answering it differently loses data. An
268
- * `allowed_values` entry is written three ways: an envelope (`{value: {type,
269
- * value}}`), a bare value (`{value: 'default'}`) or a named entry
270
- * (`{name: 'default'}`). This has always accepted all three; the engine's
271
- * clearing guard accepted only the first and the third, so a schema using the
272
- * bare form had its value ERASED on load while this function went on rendering
273
- * the display name for it — the row read "Default RBAC" collapsed and "—" when
274
- * opened, and the value was gone from the submitted data.
270
+ * over the raw stored value. The predicate lives in `helpers/allowedValues`,
271
+ * where the validator uses it too, so a value the row can name is exactly a
272
+ * value the form accepts.
275
273
  */
276
- export const findAllowedValueOption = (
277
- value: unknown,
278
- schema?: TQorusFormFieldSchema
279
- ): any | undefined => {
280
- const s = schema as
281
- { allowed_values?: any[]; element_allowed_values?: any[]; items?: any[] } | undefined;
282
- const options =
283
- (s?.allowed_values?.length && s.allowed_values) ||
284
- (s?.element_allowed_values?.length && s.element_allowed_values) ||
285
- s?.items;
286
- if (!options?.length) {
287
- return undefined;
288
- }
289
- // A stored element can be the bare value or the typed `{type, value}`
290
- // envelope the form engine round-trips; match either against either.
291
- const stored =
292
- value && typeof value === 'object' && 'value' in (value as Record<string, unknown>) ?
293
- (value as Record<string, unknown>).value
294
- : value;
295
-
296
- return options.find(
297
- (option) =>
298
- option?.value?.value === value ||
299
- option?.value === value ||
300
- option?.name === value ||
301
- option?.value?.value === stored ||
302
- option?.value === stored ||
303
- option?.name === stored
304
- );
305
- };
274
+ export { findAllowedValueOption };
306
275
 
307
276
  const getAllowedValueLabel = (
308
277
  value: unknown,
@@ -24,13 +24,18 @@ import { darken, rgba } from 'polished';
24
24
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
25
25
  import styled from 'styled-components';
26
26
  import { moveItem } from '../../../../helpers/common';
27
- import { areQorusTypesCompatible, getArgumentType } from '../../../../helpers/expressions';
27
+ import {
28
+ areQorusTypesCompatible,
29
+ expressionFitsReturnType,
30
+ getArgumentType,
31
+ } from '../../../../helpers/expressions';
28
32
  import { addMissingExpressionArgs } from '../argumentPresence';
29
33
  import { findTemplate } from '../../../../helpers/templates';
30
34
  import { validateField, validateFieldWithResult } from '../../../../helpers/validations';
31
35
  import { IQorusTypeObject, useQorusTypes } from '../../../../hooks/useQorusTypes';
32
36
  import { useReqraftStorage } from '../../../../hooks/useStorage/useStorage';
33
37
  import { usePhoneViewport } from '../../../../hooks/usePhoneViewport';
38
+ import { useCoarsePointer } from '../../../../hooks/useCoarsePointer';
34
39
  import { useTemplates } from '../../../../hooks/useTemplates';
35
40
  import { AutoFormField as auto } from '../../fields/auto/AutoFormField';
36
41
  import { SelectFormField as Select } from '../../fields/select/Select';
@@ -148,6 +153,7 @@ export const Expression = ({
148
153
  componentOverrides,
149
154
  ...props
150
155
  }: IExpressionProps) => {
156
+ const coarsePointer = useCoarsePointer();
151
157
  const types = useQorusTypes();
152
158
  const [showSummary, setShowSummary] = useState(false);
153
159
  const [confirmDialogData, setConfirmDialogData] = useState<
@@ -594,6 +600,15 @@ export const Expression = ({
594
600
  const defaultItems = useMemo(
595
601
  () =>
596
602
  expressions.value
603
+ // a condition (a field or an operand that must be true or false) offers the operations that
604
+ // give true or false; a group's members are its own business, and the operation already chosen
605
+ // stays, so a stored mismatch can be seen and fixed
606
+ .filter(
607
+ (exp) =>
608
+ isChild ||
609
+ exp.name === value?.value?.exp ||
610
+ expressionFitsReturnType(returnType as string | string[] | undefined, exp)
611
+ )
597
612
  .map((exp) => ({
598
613
  name: exp.name,
599
614
  value: exp.name,
@@ -612,7 +627,7 @@ export const Expression = ({
612
627
  ? !exp.from_server
613
628
  : true
614
629
  ),
615
- [JSON.stringify(expressions.value), serverExpression, level]
630
+ [JSON.stringify(expressions.value), serverExpression, level, isChild, returnType, value?.value?.exp]
616
631
  );
617
632
 
618
633
  // SEAM (reqraft): reqraft's `SelectFormField` calls `onChange(value)` with
@@ -879,7 +894,10 @@ export const Expression = ({
879
894
  contentStyle={{
880
895
  overflowX: 'hidden',
881
896
  }}
882
- floatingActions
897
+ // with a mouse, the actions float above the panel while it is hovered; a finger cannot hover, and a
898
+ // tap left them floating over what is above - the Visual / Text switch, where a tap meant for
899
+ // Text hit "Remove this expression" - so on a touch device they stay in the panel's own bar
900
+ floatingActions={!coarsePointer}
883
901
  actions={[
884
902
  // SEAM (reqraft): the IDE renders `AiAssistanceAction` first here;
885
903
  // consumers inject it (or anything else) via `extraActions`.
@@ -819,9 +819,12 @@ function AutoField<T = any>({
819
819
  );
820
820
  }
821
821
 
822
+ // the multi-select reads each item's `{type, value}` envelope itself, as Field.tsx gives it;
823
+ // handing it the unwrapped values left every choice `undefined`, so a pick stored an empty
824
+ // element and drew an "undefined" chip
822
825
  return (
823
826
  <MultiSelectFormField
824
- items={mappedAllowedValues}
827
+ items={rest.element_allowed_values}
825
828
  value={formatFromServerValue(value)}
826
829
  onChange={(selected) => {
827
830
  handleChange(name, formatToServerValue(selected));
@@ -36,6 +36,14 @@ export class EmittedValues<T> {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * Whether `incoming` is one of the emits still in flight, without acknowledging anything: for a render,
41
+ * which may run more than once for one value, while `isEcho` is called once the value is committed.
42
+ */
43
+ includes(incoming: T): boolean {
44
+ return this.pending.some((value) => this.equals(value, incoming));
45
+ }
46
+
39
47
  /**
40
48
  * Whether `incoming` is the echo of an emit still in flight. An echo
41
49
  * acknowledges that emit and every earlier one; anything else clears the
@@ -803,7 +803,10 @@ const TemplateFieldImpl = memo(
803
803
  }
804
804
  }, [JSON.stringify(templateValue)]);
805
805
 
806
- const showTemplateToggle = allowCustomValues && allowTemplates && !rest.arg_schema;
806
+ // an editor that takes templates into its own text has no template mode to switch to: "Use Template"
807
+ // there emptied the field and showed nothing in its place
808
+ const showTemplateToggle =
809
+ allowCustomValues && allowTemplates && !rest.arg_schema && !editorHandlesTemplates;
807
810
 
808
811
  // Only a BRACED context ref (`$data:{…}` — machine-written, nobody types
809
812
  // one) renders as the picker chip (named via `resolveTemplateLabel`)
@@ -31,6 +31,7 @@ import React, {
31
31
  import { Editor, NodeEntry, Range, Transforms } from 'slate';
32
32
  import { ReactEditor, RenderLeafProps } from 'slate-react';
33
33
  import { ILspSignatureHelp } from '../../utils/lspClient.types';
34
+ import { useEmittedValues } from '../form/fields/emittedValues';
34
35
  import { defaultSlateConverter, expandSnippet, lspPositionToOffset } from './helpers';
35
36
  import { MarkdownDoc } from './MarkdownDoc';
36
37
  import { COMPLETION_KIND_INTENTS, SMART_EDITOR_OVERLAY_EFFECT } from './styling';
@@ -359,9 +360,25 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
359
360
  enabled: enableHover,
360
361
  });
361
362
 
363
+ /* What the editor has reported and the host has not handed back yet. A host can hand an emit back
364
+ after the next key is already in the editor - one that debounces, or a render that lands between
365
+ two keys - and that stale value is not a change: rebuilding the document from it replaced the text
366
+ and put the caret at its end, so the next key followed the stale text (`name` typed came out
367
+ `nae`). See `EmittedValues`. */
368
+ const emitted = useEmittedValues<string>();
369
+ /* The value the host last handed down. The editor re-renders between keys for its own reasons (it
370
+ has just reported a new tree), and the host has not caught up then: the value it still holds is
371
+ not a change either. */
372
+ // nothing until the first value is committed, so the server is sent the first value too
373
+ const committedValueRef = useRef<string | undefined>(undefined);
374
+
362
375
  const slateValue = useMemo(() => {
363
- // Reuse the cache when `value` is just the editor's own typing echo.
364
- if (slateValueRef.current !== null && value === lastPlainTextRef.current) {
376
+ // Reuse the cache unless the host handed down something new: the same value again, the editor's
377
+ // own typing echo - the latest, or one still in flight - is not
378
+ if (
379
+ slateValueRef.current !== null &&
380
+ (value === committedValueRef.current || value === lastPlainTextRef.current || emitted.includes(value))
381
+ ) {
365
382
  return slateValueRef.current;
366
383
  }
367
384
  const next = converter.toSlateNodes(value);
@@ -376,8 +393,14 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
376
393
  this one; typing already sent its text, and is not resent. */
377
394
  const { didChange: syncDocument } = session;
378
395
  useEffect(() => {
396
+ // the echo of an emit is acknowledged, and is not sent: the server already has the newer text
397
+ // typing sent; anything else is a change from outside
398
+ if (committedValueRef.current === value) return;
399
+ committedValueRef.current = value;
400
+ if (emitted.isEcho(value)) return;
401
+ lastPlainTextRef.current = value;
379
402
  syncDocument(value);
380
- }, [syncDocument, value]);
403
+ }, [syncDocument, value, emitted]);
381
404
 
382
405
  const diagnosticDecorate = useLspDiagnosticDecorations(
383
406
  session.diagnostics,
@@ -486,6 +509,7 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
486
509
 
487
510
  if (plainText !== lastPlainTextRef.current) {
488
511
  lastPlainTextRef.current = plainText;
512
+ emitted.record(plainText);
489
513
  // CRITICAL: the next render's `value` equals `lastPlainTextRef` (the
490
514
  // typing echo), so `slateValue`'s cache returns the live nodes set
491
515
  // above — leave them stale and downstream hooks (signature help,
@@ -499,7 +523,7 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
499
523
  autocomplete.onSlateChange(editorRef.current as any, newNodes);
500
524
  }
501
525
  },
502
- [onChange, session, readOnly, autocomplete, converter]
526
+ [onChange, session, readOnly, autocomplete, converter, emitted]
503
527
  );
504
528
 
505
529
  // Replace mode: open the dropdown at the chip's DOM rect (not the
@@ -132,6 +132,8 @@ export function useLspSession(
132
132
 
133
133
  c.onReady(setIsReady);
134
134
 
135
+ // the session ends when the editor goes: a connection it closes itself has not failed
136
+ let disposed = false;
135
137
  c.connect()
136
138
  .then(() => {
137
139
  // Initialize completed — mirror the captured server
@@ -152,12 +154,18 @@ export function useLspSession(
152
154
  setIsReady(true);
153
155
  })
154
156
  .catch((err) => {
157
+ if (disposed) {
158
+ // the editor went before the connection opened (Text mode left again, a form closed): it was
159
+ // closed on purpose, which is not a failure to report
160
+ return;
161
+ }
155
162
  // eslint-disable-next-line no-console
156
163
  console.error('[LSP session] Connect failed:', err);
157
164
  setIsReady(false);
158
165
  });
159
166
 
160
167
  return () => {
168
+ disposed = true;
161
169
  c.disconnect();
162
170
  clientRef.current = null;
163
171
  serverTextRef.current = null;
@@ -525,6 +525,10 @@ export const AboveTheComposer: Story = {
525
525
  },
526
526
  };
527
527
 
528
+ /* The kinds for the image stories: Qogs first, then a few font-icon kinds to
529
+ * compare against. */
530
+ const IMAGE_KINDS = ['qog', 'workflow', 'service', 'job'];
531
+
528
532
  const menuImages = (canvasElement: HTMLElement) =>
529
533
  Array.from(canvasElement.querySelectorAll('.reqore-menu-item img')).map((img) =>
530
534
  img.getAttribute('src')
@@ -540,12 +544,20 @@ export const KindImage: Story = {
540
544
  },
541
545
  },
542
546
  decorators: [darkTheme],
543
- render: () => <Hosted initialKind='qog' resolveInterfaceIcon={storyKindMark} />,
547
+ // A short kind list with Qogs first, so the Qogs kind and its mark sit at the
548
+ // top of the column in the captured frame rather than below its fold.
549
+ render: () => (
550
+ <Hosted kinds={IMAGE_KINDS} initialKind='qog' resolveInterfaceIcon={storyKindMark} />
551
+ ),
544
552
  async play({ canvasElement }) {
545
553
  // the Qogs kind row plus its four interfaces
546
554
  await waitFor(() =>
547
555
  expect(menuImages(canvasElement).filter((src) => src === STORY_QOG_IMAGE)).toHaveLength(5)
548
556
  );
557
+ // the Qogs kind is the first kind row, so it is in the frame
558
+ await expect(
559
+ canvasElement.querySelector('.reqore-menu-item')?.querySelector('img')?.getAttribute('src')
560
+ ).toBe(STORY_QOG_IMAGE);
549
561
  },
550
562
  };
551
563
 
@@ -559,7 +571,9 @@ export const KindImageFailsToLoad: Story = {
559
571
  },
560
572
  },
561
573
  decorators: [darkTheme],
562
- render: () => <Hosted initialKind='qog' resolveInterfaceIcon={storyBrokenKindMark} />,
574
+ render: () => (
575
+ <Hosted kinds={IMAGE_KINDS} initialKind='qog' resolveInterfaceIcon={storyBrokenKindMark} />
576
+ ),
563
577
  async play({ canvasElement }) {
564
578
  const canvas = within(canvasElement);
565
579
  await canvas.findByRole('button', { name: 'telegram-intake' });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Is a stored value one of a field's fixed choices?
3
+ *
4
+ * Two questions share this one predicate: the read-first row asks it to LABEL a
5
+ * value with the choice's display name, and the validator asks it to decide
6
+ * whether a value is one of the choices at all. Answering them differently is
7
+ * how values were lost: a value the row could name was erased by a narrower
8
+ * test, and the emptied form was then saved.
9
+ */
10
+ import { TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit';
11
+ import isEqual from 'lodash/isEqual';
12
+ import { richtextHasTag, richtextToString } from './common';
13
+ import { getListElementValue } from './options';
14
+ import { isValueTemplate } from './templates';
15
+
16
+ const isStructured = (value: unknown): boolean => value !== null && typeof value === 'object';
17
+
18
+ /**
19
+ * Prefer the matching allowed_values entry's display_name (fallback `name`)
20
+ * over the raw stored value.
21
+ *
22
+ * A LIST carries its options under `element_allowed_values` — they constrain
23
+ * each element, not the list itself — so a multi-select that was missing here
24
+ * printed what it stores rather than what you picked: `orders, batch` for
25
+ * fields whose picker reads "Orders, Batch". The gap shows worst exactly where
26
+ * allowed values earn their keep, since the stored form is often not readable
27
+ * at all (a permission code, `PO_REQUIRE_TYPES`, an app-specific id).
28
+ *
29
+ * Exported (as `findAllowedValueOption`) because the form engine's value
30
+ * validation has to answer the SAME question — "is this stored value one of the
31
+ * declared choices?" — and answering it differently loses data. An
32
+ * `allowed_values` entry is written three ways: an envelope (`{value: {type,
33
+ * value}}`), a bare value (`{value: 'default'}`) or a named entry
34
+ * (`{name: 'default'}`). This has always accepted all three; the engine's
35
+ * clearing guard accepted only the first and the third, so a schema using the
36
+ * bare form had its value ERASED on load while this function went on rendering
37
+ * the display name for it — the row read "Default RBAC" collapsed and "—" when
38
+ * opened, and the value was gone from the submitted data.
39
+ */
40
+ export const findAllowedValueOption = (
41
+ value: unknown,
42
+ schema?: TQorusFormFieldSchema
43
+ ): any | undefined => {
44
+ const s = schema as
45
+ { allowed_values?: any[]; element_allowed_values?: any[]; items?: any[] } | undefined;
46
+ const options =
47
+ (s?.allowed_values?.length && s.allowed_values) ||
48
+ (s?.element_allowed_values?.length && s.element_allowed_values) ||
49
+ s?.items;
50
+ if (!options?.length) {
51
+ return undefined;
52
+ }
53
+ // A stored element can be the bare value or the typed `{type, value}`
54
+ // envelope the form engine round-trips; match either against either.
55
+ const stored =
56
+ value && typeof value === 'object' && 'value' in (value as Record<string, unknown>) ?
57
+ (value as Record<string, unknown>).value
58
+ : value;
59
+
60
+ // A rich-text document is its text: a field with fixed choices can hold the document an editor wrote
61
+ // (for example while its choices could not be loaded), and the document for "Case" is the choice "Case".
62
+ const text = Array.isArray(stored) ? richtextToString(stored as never) : undefined;
63
+
64
+ return options.find(
65
+ (option) =>
66
+ option?.value?.value === value ||
67
+ option?.value === value ||
68
+ option?.name === value ||
69
+ option?.value?.value === stored ||
70
+ option?.value === stored ||
71
+ option?.name === stored ||
72
+ (text !== undefined &&
73
+ (option?.value?.value === text || option?.value === text || option?.name === text)) ||
74
+ // A hash- or list-valued choice is a fresh object every time the schema
75
+ // arrives, so identity can never match it; its contents can.
76
+ (isStructured(stored) &&
77
+ (isEqual(option?.value?.value, stored) || isEqual(option?.value, stored)))
78
+ );
79
+ };
80
+
81
+ /**
82
+ * A Slate rich-text document: a non-empty array of element nodes, each with
83
+ * `children`. Told apart from a LIST value, which is an array of elements.
84
+ */
85
+ const isRichTextDocument = (value: unknown): boolean =>
86
+ Array.isArray(value) &&
87
+ value.length > 0 &&
88
+ value.every(
89
+ (node) => !!node && typeof node === 'object' && Array.isArray((node as any).children)
90
+ );
91
+
92
+ /** A value resolved only when the interface runs — never one of the choices. */
93
+ const isResolvedAtRunTime = (value: unknown): boolean =>
94
+ (typeof value === 'string' && isValueTemplate(value)) || richtextHasTag(value);
95
+
96
+ const isEmptyChoiceValue = (value: unknown): boolean =>
97
+ value === undefined ||
98
+ value === null ||
99
+ value === '' ||
100
+ (Array.isArray(value) && value.length === 0);
101
+
102
+ /** The fields of a schema entry the choice check reads. */
103
+ export interface IChoiceFieldSchema {
104
+ allowed_values?: any[];
105
+ allowed_values_creatable?: boolean;
106
+ element_allowed_values?: any[];
107
+ element_allowed_values_creatable?: boolean;
108
+ multiselect?: boolean;
109
+ /** Set when the value is an expression, which is resolved at run time. */
110
+ isFunction?: boolean;
111
+ is_expression?: boolean;
112
+ }
113
+
114
+ /**
115
+ * The parts of a stored value that are not among the field's fixed choices.
116
+ *
117
+ * Empty when the value is acceptable, or when there is nothing to judge it
118
+ * against:
119
+ * - an empty value (whether one is needed is the required check's question);
120
+ * - an expression, a template string or a document holding a template tag,
121
+ * all of which are resolved when the interface runs;
122
+ * - `allowed_values: []` — the server has no choices to offer right now, which
123
+ * is "unknown", not "nothing is allowed";
124
+ * - creatable choices, where any value may be added.
125
+ *
126
+ * A single-valued field is judged as a whole. A multi-select field, and a list
127
+ * whose elements are constrained by `element_allowed_values`, are judged per
128
+ * element, so the answer names exactly the elements that are not choices.
129
+ */
130
+ export const getUnlistedChoices = (value: unknown, field?: IChoiceFieldSchema): unknown[] => {
131
+ if (!field || isEmptyChoiceValue(value) || field.isFunction || field.is_expression) {
132
+ return [];
133
+ }
134
+
135
+ const judgeElements = (elements: unknown[], choices: any[]): unknown[] =>
136
+ elements.filter((element) => {
137
+ const stored = getListElementValue(element);
138
+ if (isEmptyChoiceValue(stored) || isResolvedAtRunTime(stored)) {
139
+ return false;
140
+ }
141
+ if ((element as { is_expression?: boolean })?.is_expression) {
142
+ return false;
143
+ }
144
+ return !findAllowedValueOption(element, { allowed_values: choices } as never);
145
+ });
146
+
147
+ if (isResolvedAtRunTime(value)) {
148
+ return [];
149
+ }
150
+
151
+ if (field.allowed_values?.length && !field.allowed_values_creatable) {
152
+ if (findAllowedValueOption(value, { allowed_values: field.allowed_values } as never)) {
153
+ return [];
154
+ }
155
+ // A multi-select holds a list of choices; any list that is not itself a
156
+ // choice is judged element by element.
157
+ if (Array.isArray(value) && !isRichTextDocument(value)) {
158
+ return judgeElements(value, field.allowed_values);
159
+ }
160
+ return [value];
161
+ }
162
+
163
+ if (
164
+ field.element_allowed_values?.length &&
165
+ !field.element_allowed_values_creatable &&
166
+ !field.allowed_values_creatable &&
167
+ Array.isArray(value) &&
168
+ !isRichTextDocument(value)
169
+ ) {
170
+ return judgeElements(value, field.element_allowed_values);
171
+ }
172
+
173
+ return [];
174
+ };
175
+
176
+ /** How a value not among the choices is named in the message. */
177
+ const describeChoiceValue = (value: unknown): string => {
178
+ const stored = getListElementValue(value);
179
+ if (typeof stored === 'string') {
180
+ return stored;
181
+ }
182
+ if (isRichTextDocument(stored)) {
183
+ return richtextToString(stored as never);
184
+ }
185
+ if (stored === null || typeof stored !== 'object') {
186
+ return String(stored);
187
+ }
188
+ try {
189
+ return JSON.stringify(stored);
190
+ } catch {
191
+ return String(stored);
192
+ }
193
+ };
194
+
195
+ /**
196
+ * Why a stored value is not acceptable for a field with fixed choices, or
197
+ * `undefined` when it is (see {@link getUnlistedChoices} for what is judged).
198
+ *
199
+ * The value is kept — it is never erased for not being a choice — and this
200
+ * reason is what makes the field invalid, so the form shows it under "Needs
201
+ * attention" and blocks submitting until the author picks a choice.
202
+ */
203
+ export const getUnlistedChoiceReason = (
204
+ value: unknown,
205
+ field?: IChoiceFieldSchema
206
+ ): string | undefined => {
207
+ const unlisted = getUnlistedChoices(value, field);
208
+ if (!unlisted.length) {
209
+ return undefined;
210
+ }
211
+ const names = unlisted.map((item) => `"${describeChoiceValue(item)}"`);
212
+ if (names.length === 1) {
213
+ return `${names[0]} is not one of the choices`;
214
+ }
215
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} are not among the choices`;
216
+ };
@@ -93,6 +93,20 @@ export const getDefaultValue = (schema?: TQorusFormFieldSchema): unknown | undef
93
93
  return undefined;
94
94
  };
95
95
 
96
+ /**
97
+ * Whether a rich-text document contains a template tag (a `$local:...`-style reference drawn as a chip).
98
+ *
99
+ * Such a value is resolved when the interface runs, so it can never be one of a field's fixed choices and must
100
+ * not be judged against them.
101
+ */
102
+ export const richtextHasTag = (richtext: unknown): boolean => {
103
+ const walk = (node: any): boolean =>
104
+ !!node &&
105
+ typeof node === 'object' &&
106
+ (node.type === 'tag' || (Array.isArray(node.children) && node.children.some(walk)));
107
+ return Array.isArray(richtext) && richtext.some(walk);
108
+ };
109
+
96
110
  export const richtextToString = (richtext: IReqoreRichTextEditorProps['value']): string => {
97
111
  // A `richtext` ui_type option can still hold a plain scalar value (set
98
112
  // programmatically or loaded from the server before the editor ever touched
@@ -100,3 +100,28 @@ export const areQorusTypesCompatible = (
100
100
  // If none of the types are compatible return false
101
101
  return false;
102
102
  };
103
+
104
+ /**
105
+ * Whether an operation is offered where its result must be of `returnType`: a condition (`bool`) is
106
+ * built from the operations that give true or false, not from every operation the server has.
107
+ *
108
+ * Only a condition narrows the list. Any other type keeps every operation: a value of one type is often
109
+ * made from an operation that gives another (text from rich text, a number from an int), and the
110
+ * mismatch message after the choice says when it does not fit. An operation whose result is known
111
+ * only once it is built (`any`, or the type of its first argument) is offered everywhere.
112
+ */
113
+ export const expressionFitsReturnType = (
114
+ returnType: string | string[] | undefined,
115
+ expression: { ui_return_type?: string; return_type_first_arg?: boolean }
116
+ ): boolean => {
117
+ const types = isArray(returnType) ? returnType : returnType ? [returnType] : [];
118
+ const condition = types.length > 0 && types.every((type) => /^\*?(bool|boolean)$/.test(type));
119
+ if (!condition) {
120
+ return true;
121
+ }
122
+ const result = expression.ui_return_type;
123
+ if (expression.return_type_first_arg || !result || result === 'any' || result === 'auto') {
124
+ return true;
125
+ }
126
+ return /^\*?(bool|boolean)$/.test(result);
127
+ };
@@ -16,6 +16,7 @@ import { isOptionInterfaceUiType } from './optionUiTypes';
16
16
  import { getListElementValue, getOptionsFromRequiredGroups } from './options';
17
17
  import { IProviderType, TVariableActionValue, maybeBuildOptionProvider } from './providerValue';
18
18
  import { getTemplateKey, getTemplateValue, isValueTemplate } from './templates';
19
+ import { getUnlistedChoiceReason } from './allowedValues';
19
20
 
20
21
  /** The five cron fields, in order, as a schedule hash may name them. */
21
22
  const CRON_FIELDS: [string, string][] = [
@@ -309,6 +310,21 @@ export const _validateField = (
309
310
  }
310
311
  }
311
312
 
313
+ // A value that is not one of the field's fixed choices is INVALID — never
314
+ // erased. The form used to drop such a value when the choices arrived, and
315
+ // three times the dropped value was a real one (a document naming a choice,
316
+ // a bare-declared choice, a value whose choices had not loaded yet) that the
317
+ // emptied form then saved. Keeping it and saying why it cannot be submitted
318
+ // leaves the author to decide. Templates and expression values returned
319
+ // above; `allowed_values: []` and creatable choices are not judged. An
320
+ // `expression` is never judged either: the expression branch above recurses
321
+ // here with the field's schema (choices included) and the AST as the value.
322
+ const unlistedChoiceReason =
323
+ type === 'expression' ? undefined : getUnlistedChoiceReason(value, field);
324
+ if (unlistedChoiceReason) {
325
+ return invalidResult(unlistedChoiceReason);
326
+ }
327
+
312
328
  // An interface type names another Qorus object by its name, so a non-empty
313
329
  // string is the whole contract. `connection` is the exception: it carries
314
330
  // enablement and authentication state of its own, checked below.
@@ -0,0 +1,30 @@
1
+ // Copyright 2026 Qore Technologies, s.r.o.
2
+ import { useEffect, useState } from 'react';
3
+
4
+ /** The pointer a finger is: it cannot hover. */
5
+ export const COARSE_POINTER_QUERY = '(pointer: coarse)';
6
+
7
+ /**
8
+ * Whether the primary pointer is coarse (a finger), from the media query itself.
9
+ *
10
+ * Starts `false` and settles after mount, so a server render and the first paint agree.
11
+ */
12
+ export const useCoarsePointer = (): boolean => {
13
+ const [coarse, setCoarse] = useState<boolean>(false);
14
+
15
+ useEffect(() => {
16
+ if (typeof window === 'undefined' || !window.matchMedia) {
17
+ return undefined;
18
+ }
19
+
20
+ const query = window.matchMedia(COARSE_POINTER_QUERY);
21
+ const sync = () => setCoarse(query.matches);
22
+
23
+ sync();
24
+ query.addEventListener?.('change', sync);
25
+
26
+ return () => query.removeEventListener?.('change', sync);
27
+ }, []);
28
+
29
+ return coarse;
30
+ };