@qoretechnologies/reqraft 0.10.57 → 0.10.58-pr.129.g2af0cf5

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 (131) hide show
  1. package/design/FORM_ENGINE_COMPACT.md +23 -5
  2. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  3. package/dist/components/form/engine/CompactToolbar.js +2 -2
  4. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +71 -62
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  9. package/dist/components/form/engine/OptionFieldMessages.js +6 -3
  10. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  11. package/dist/components/form/engine/readFirst.d.ts +16 -20
  12. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  13. package/dist/components/form/engine/readFirst.js +16 -49
  14. package/dist/components/form/engine/readFirst.js.map +1 -1
  15. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -1
  16. package/dist/components/form/engine/variants/VariantCalmTable.js +1 -1
  17. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -1
  18. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -1
  19. package/dist/components/form/engine/variants/variantModel.js +8 -3
  20. package/dist/components/form/engine/variants/variantModel.js.map +1 -1
  21. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  22. package/dist/components/form/expressions/builder/index.js +22 -13
  23. package/dist/components/form/expressions/builder/index.js.map +1 -1
  24. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  25. package/dist/components/form/fields/array/ArrayAuto.js +8 -0
  26. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  27. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  28. package/dist/components/form/fields/array/ArrayAutoField.js +8 -0
  29. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  30. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  31. package/dist/components/form/fields/auto/AutoFormField.js +4 -1
  32. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  33. package/dist/components/form/fields/binary/Binary.d.ts.map +1 -1
  34. package/dist/components/form/fields/binary/Binary.js +10 -0
  35. package/dist/components/form/fields/binary/Binary.js.map +1 -1
  36. package/dist/components/form/fields/emittedValues.d.ts +38 -0
  37. package/dist/components/form/fields/emittedValues.d.ts.map +1 -0
  38. package/dist/components/form/fields/emittedValues.js +67 -0
  39. package/dist/components/form/fields/emittedValues.js.map +1 -0
  40. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  41. package/dist/components/form/fields/long-string/LongString.js +24 -8
  42. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  43. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  44. package/dist/components/form/fields/number/Number.js +9 -0
  45. package/dist/components/form/fields/number/Number.js.map +1 -1
  46. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  47. package/dist/components/form/fields/rich-text/RichText.js +10 -0
  48. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  49. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  50. package/dist/components/form/fields/template/TemplateField.js +3 -1
  51. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  52. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  53. package/dist/components/smartEditor/useLspSession.js +8 -0
  54. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  55. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +21 -5
  56. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  57. package/dist/components/supportTicket/InterfaceReferenceTags.js +24 -8
  58. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  59. package/dist/components/supportTicket/ReferencePicker.d.ts +5 -4
  60. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  61. package/dist/components/supportTicket/ReferencePicker.js +27 -2
  62. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  63. package/dist/components/supportTicket/meta.d.ts +16 -0
  64. package/dist/components/supportTicket/meta.d.ts.map +1 -1
  65. package/dist/components/supportTicket/meta.js +6 -1
  66. package/dist/components/supportTicket/meta.js.map +1 -1
  67. package/dist/components/supportTicket/useLoadedImages.d.ts +18 -0
  68. package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -0
  69. package/dist/components/supportTicket/useLoadedImages.js +72 -0
  70. package/dist/components/supportTicket/useLoadedImages.js.map +1 -0
  71. package/dist/components/ticketReferences/TicketReferences.d.ts +9 -5
  72. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  73. package/dist/components/ticketReferences/TicketReferences.js +22 -9
  74. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  75. package/dist/components/ticketThread/TicketThread.d.ts +10 -6
  76. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  77. package/dist/components/ticketThread/TicketThread.js +2 -2
  78. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  79. package/dist/helpers/allowedValues.d.ts +71 -0
  80. package/dist/helpers/allowedValues.d.ts.map +1 -0
  81. package/dist/helpers/allowedValues.js +183 -0
  82. package/dist/helpers/allowedValues.js.map +1 -0
  83. package/dist/helpers/common.d.ts +7 -0
  84. package/dist/helpers/common.d.ts.map +1 -1
  85. package/dist/helpers/common.js +16 -1
  86. package/dist/helpers/common.js.map +1 -1
  87. package/dist/helpers/expressions.d.ts +13 -0
  88. package/dist/helpers/expressions.d.ts.map +1 -1
  89. package/dist/helpers/expressions.js +23 -1
  90. package/dist/helpers/expressions.js.map +1 -1
  91. package/dist/helpers/validations.d.ts.map +1 -1
  92. package/dist/helpers/validations.js +14 -0
  93. package/dist/helpers/validations.js.map +1 -1
  94. package/dist/utils/fetch.d.ts +4 -0
  95. package/dist/utils/fetch.d.ts.map +1 -1
  96. package/dist/utils/fetch.js +6 -1
  97. package/dist/utils/fetch.js.map +1 -1
  98. package/package.json +1 -1
  99. package/src/components/form/engine/CompactToolbar.tsx +8 -3
  100. package/src/components/form/engine/FormEngine.stories.tsx +206 -13
  101. package/src/components/form/engine/FormEngine.tsx +70 -57
  102. package/src/components/form/engine/OptionFieldMessages.tsx +7 -5
  103. package/src/components/form/engine/readFirst.ts +19 -50
  104. package/src/components/form/engine/variants/VariantCalmTable.tsx +3 -1
  105. package/src/components/form/engine/variants/variantModel.ts +10 -3
  106. package/src/components/form/expressions/builder/index.tsx +15 -2
  107. package/src/components/form/fields/array/ArrayAuto.tsx +10 -1
  108. package/src/components/form/fields/array/ArrayAutoField.tsx +10 -1
  109. package/src/components/form/fields/auto/AutoFormField.tsx +4 -1
  110. package/src/components/form/fields/binary/Binary.tsx +10 -0
  111. package/src/components/form/fields/emittedValues.ts +62 -0
  112. package/src/components/form/fields/long-string/LongString.tsx +28 -8
  113. package/src/components/form/fields/number/Number.tsx +9 -0
  114. package/src/components/form/fields/rich-text/RichText.tsx +10 -0
  115. package/src/components/form/fields/template/TemplateField.tsx +4 -1
  116. package/src/components/smartEditor/useLspSession.ts +8 -0
  117. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +86 -0
  118. package/src/components/supportTicket/InterfaceReferenceTags.tsx +44 -11
  119. package/src/components/supportTicket/ReferencePicker.stories.tsx +65 -0
  120. package/src/components/supportTicket/ReferencePicker.tsx +26 -5
  121. package/src/components/supportTicket/TicketReplyBox.stories.tsx +5 -2
  122. package/src/components/supportTicket/__fixtures__/kindMarks.ts +28 -0
  123. package/src/components/supportTicket/meta.ts +21 -0
  124. package/src/components/supportTicket/useLoadedImages.ts +73 -0
  125. package/src/components/ticketReferences/TicketReferences.tsx +27 -7
  126. package/src/components/ticketThread/TicketThread.tsx +11 -3
  127. package/src/helpers/allowedValues.ts +216 -0
  128. package/src/helpers/common.ts +14 -0
  129. package/src/helpers/expressions.ts +25 -0
  130. package/src/helpers/validations.ts +16 -0
  131. package/src/utils/fetch.ts +10 -1
@@ -4,6 +4,13 @@ import { ComponentProps, useState } from 'react';
4
4
  import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
5
5
  import { StoryMeta } from '../../types';
6
6
  import { InterfaceReferenceTags } from './InterfaceReferenceTags';
7
+ import {
8
+ STORY_BROKEN_IMAGE,
9
+ STORY_QOG_IMAGE,
10
+ storyBrokenKindMark,
11
+ storyKindMark,
12
+ } from './__fixtures__/kindMarks';
13
+ import { whenImageSettled } from './useLoadedImages';
7
14
  import { IInterfaceReference } from './meta';
8
15
  import { ReferencePicker, TReferencePickerItem } from './ReferencePicker';
9
16
  import { TicketReplyBox } from './TicketReplyBox';
@@ -517,3 +524,61 @@ export const AboveTheComposer: Story = {
517
524
  await expect(canvas.getAllByText('order-sync:1.2').length).toBeGreaterThan(0);
518
525
  },
519
526
  };
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
+
532
+ const menuImages = (canvasElement: HTMLElement) =>
533
+ Array.from(canvasElement.querySelectorAll('.reqore-menu-item img')).map((img) =>
534
+ img.getAttribute('src')
535
+ );
536
+
537
+ export const KindImage: Story = {
538
+ parameters: {
539
+ docs: {
540
+ description: {
541
+ story:
542
+ 'Renders the picker with a host resolver that gives Qogs an image: the Qogs kind and each Qog in the list show the green Qog mark; every other kind keeps its font icon.',
543
+ },
544
+ },
545
+ },
546
+ decorators: [darkTheme],
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
+ ),
552
+ async play({ canvasElement }) {
553
+ // the Qogs kind row plus its four interfaces
554
+ await waitFor(() =>
555
+ expect(menuImages(canvasElement).filter((src) => src === STORY_QOG_IMAGE)).toHaveLength(5)
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);
561
+ },
562
+ };
563
+
564
+ export const KindImageFailsToLoad: Story = {
565
+ parameters: {
566
+ docs: {
567
+ description: {
568
+ story:
569
+ 'Renders the picker with a Qog image that cannot load: the Qogs kind and its interfaces keep the font icon instead of a broken picture.',
570
+ },
571
+ },
572
+ },
573
+ decorators: [darkTheme],
574
+ render: () => (
575
+ <Hosted kinds={IMAGE_KINDS} initialKind='qog' resolveInterfaceIcon={storyBrokenKindMark} />
576
+ ),
577
+ async play({ canvasElement }) {
578
+ const canvas = within(canvasElement);
579
+ await canvas.findByRole('button', { name: 'telegram-intake' });
580
+ // Once the load has failed, no picture is drawn.
581
+ await whenImageSettled(STORY_BROKEN_IMAGE);
582
+ await expect(menuImages(canvasElement)).toEqual([]);
583
+ },
584
+ };
@@ -6,7 +6,6 @@ import {
6
6
  ReqoreSpinner,
7
7
  } from '@qoretechnologies/reqore';
8
8
  import { TReqoreBadge } from '@qoretechnologies/reqore/dist/components/Button';
9
- import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
10
9
  import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
11
10
  import styled from 'styled-components';
12
11
  import { thinScrollbar } from '../../helpers/scrollbar';
@@ -15,7 +14,10 @@ import {
15
14
  defaultInterfaceIcon,
16
15
  defaultInterfaceKindLabel,
17
16
  IInterfaceReference,
17
+ toInterfaceKindMark,
18
+ TResolveInterfaceIcon,
18
19
  } from './meta';
20
+ import { useLoadedImages } from './useLoadedImages';
19
21
 
20
22
  /*
21
23
  * The interface browser: kinds on one side, the selected kind's interfaces on the
@@ -227,9 +229,11 @@ export interface IReferencePickerProps {
227
229
  onRemove?: (reference: IInterfaceReference) => void;
228
230
  /** the whole picker is inert (e.g. a reply is in flight) */
229
231
  disabled?: boolean;
230
- /** Per-kind icon. Consumers with their own icon vocabulary (the IDE) pass one; the
232
+ /** Per-kind icon: a font icon, or a mark `{ icon, image }` whose image (e.g. the Qog
233
+ * logo) is drawn once it loads, with the font icon before that and instead of it if
234
+ * it fails. Consumers with their own icon vocabulary (the IDE) pass one; the
231
235
  * built-in per-kind default is used when omitted. */
232
- resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
236
+ resolveInterfaceIcon?: TResolveInterfaceIcon;
233
237
  /**
234
238
  * Per-kind reading name — "Value maps" for `value-map`. The kind id is a wire value
235
239
  * and never reaches the user; this is what does. Search matches both, so typing
@@ -401,11 +405,28 @@ export const ReferencePicker = ({
401
405
  still own the scroll and the layout; the menus render transparent inside them.
402
406
  Only menu items go inside a `ReqoreMenu`: it clones every child with its own props,
403
407
  so a spinner or a note nested in one would be handed props it can't take. */
408
+ /* A kind's icon on a menu item: its font icon, and its image (the Qog logo) once
409
+ that has loaded — never a broken picture while it loads or if it fails. */
410
+ const kindMarks = new Map(
411
+ Array.from(new Set([...visibleKinds, kind].filter(Boolean) as string[])).map((option) => [
412
+ option,
413
+ toInterfaceKindMark(resolveInterfaceIcon(option)),
414
+ ])
415
+ );
416
+ const isImageLoaded = useLoadedImages(Array.from(kindMarks.values()).map((mark) => mark.image));
417
+ const kindIconProps = (option: string) => {
418
+ const mark = kindMarks.get(option) ?? toInterfaceKindMark(resolveInterfaceIcon(option));
419
+ return {
420
+ icon: mark.icon,
421
+ leftIconProps: isImageLoaded(mark.image) ? { image: mark.image } : undefined,
422
+ };
423
+ };
424
+
404
425
  const kindItems = visibleKinds.map((option) => (
405
426
  <ReqoreMenuItem
406
427
  key={option}
407
428
  className={matchesKind(option) ? undefined : DIMMED_CLASS}
408
- icon={resolveInterfaceIcon(option)}
429
+ {...kindIconProps(option)}
409
430
  label={resolveKindLabel(option)}
410
431
  selected={option === kind}
411
432
  disabled={disabled}
@@ -452,7 +473,7 @@ export const ReferencePicker = ({
452
473
  <ReqoreMenuItem
453
474
  key={name}
454
475
  className={isPicked ? PICKED_CLASS : undefined}
455
- icon={resolveInterfaceIcon(kind)}
476
+ {...kindIconProps(kind)}
456
477
  rightIcon={isPicked ? 'CheckLine' : 'AddLine'}
457
478
  label={name}
458
479
  description={description}
@@ -284,8 +284,11 @@ export const QogTriggerAppLogo: Story = {
284
284
  async play({ canvasElement }) {
285
285
  const canvas = within(canvasElement);
286
286
  await expect(canvas.getByText('telegram-intake')).toBeInTheDocument();
287
- // the trigger-app / stored-logo qogs render their app logo image
288
- await expect(canvasElement.innerHTML).toContain('data:image');
287
+ // the trigger-app / stored-logo qogs render their app logo image once it has
288
+ // loaded (the qog glyph stands in until then)
289
+ await waitFor(() =>
290
+ expect(canvasElement.querySelectorAll('.reqore-tag img').length).toBe(3)
291
+ );
289
292
  },
290
293
  };
291
294
 
@@ -0,0 +1,28 @@
1
+ import { defaultInterfaceIcon, TInterfaceKindIcon } from '../meta';
2
+
3
+ /* Stand-ins for a host's kind marks in stories. Inline, so they load offline. */
4
+
5
+ /** A green Qog-like mark: loads. */
6
+ export const STORY_QOG_IMAGE = `data:image/svg+xml;utf8,${encodeURIComponent(
7
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#2e9d4f"/><circle cx="12" cy="12" r="5" fill="#fff"/></svg>'
8
+ )}`;
9
+
10
+ /** A trigger app's logo: loads. */
11
+ export const STORY_APP_LOGO = `data:image/svg+xml;utf8,${encodeURIComponent(
12
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="12" fill="#229ed9"/></svg>'
13
+ )}`;
14
+
15
+ /** An image that can never load (malformed data URI). */
16
+ export const STORY_BROKEN_IMAGE = 'data:image/png;base64,not-an-image';
17
+
18
+ /** The IDE's resolver: a Qog is its green mark (font icon fallback); the rest, font icons. */
19
+ export const storyKindMark = (kind: string): TInterfaceKindIcon =>
20
+ kind === 'fsm' || kind === 'qog' ?
21
+ { icon: 'FlowChart', image: STORY_QOG_IMAGE }
22
+ : defaultInterfaceIcon(kind);
23
+
24
+ /** The same, with a Qog image that fails to load. */
25
+ export const storyBrokenKindMark = (kind: string): TInterfaceKindIcon =>
26
+ kind === 'fsm' || kind === 'qog' ?
27
+ { icon: 'FlowChart', image: STORY_BROKEN_IMAGE }
28
+ : defaultInterfaceIcon(kind);
@@ -144,6 +144,27 @@ const DEFAULT_INTERFACE_ICONS: Record<string, IReqoreIconName> = {
144
144
  export const defaultInterfaceIcon = (kind: string): IReqoreIconName =>
145
145
  DEFAULT_INTERFACE_ICONS[kind] ?? 'CodeBoxLine';
146
146
 
147
+ /**
148
+ * An interface kind's mark: a font icon, and optionally an image (e.g. the green
149
+ * Qog logo). The font icon is what shows while the image loads, and instead of it
150
+ * when there is none or it cannot load. Same shape as qorus-ide's
151
+ * `interfaceKindMark()`, so the IDE can return that directly.
152
+ */
153
+ export interface IInterfaceKindMark {
154
+ icon: IReqoreIconName;
155
+ image?: string;
156
+ }
157
+
158
+ /** What a `resolveInterfaceIcon` may return: a font icon, or a mark with an image. */
159
+ export type TInterfaceKindIcon = IReqoreIconName | IInterfaceKindMark;
160
+
161
+ /** Resolve the icon for an interface kind (see `TInterfaceKindIcon`). */
162
+ export type TResolveInterfaceIcon = (kind: string) => TInterfaceKindIcon;
163
+
164
+ /** A resolver's answer as a mark, so callers handle one shape. */
165
+ export const toInterfaceKindMark = (icon: TInterfaceKindIcon): IInterfaceKindMark =>
166
+ typeof icon === 'string' ? { icon } : icon;
167
+
147
168
  // The reading name for each interface kind. The kind id (`value-map`, `ai-collection`)
148
169
  // is a wire value — it's what a host fetches with and what gets stored on the reference
149
170
  // — so it must never be what the user reads. These are plural because they name a
@@ -0,0 +1,73 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /* What is known about each image URL, shared by every surface for the life of
4
+ the page: an image that loaded once is drawn at once the next time, and one
5
+ that failed is not tried again on every render. */
6
+ const imageStatus = new Map<string, 'loaded' | 'failed'>();
7
+ const pending = new Map<string, Promise<void>>();
8
+
9
+ const loadImage = (src: string): Promise<void> => {
10
+ let promise = pending.get(src);
11
+ if (!promise) {
12
+ promise = new Promise<void>((resolve) => {
13
+ const image = new Image();
14
+ image.onload = () => {
15
+ imageStatus.set(src, 'loaded');
16
+ resolve();
17
+ };
18
+ image.onerror = () => {
19
+ imageStatus.set(src, 'failed');
20
+ resolve();
21
+ };
22
+ image.src = src;
23
+ }).finally(() => pending.delete(src));
24
+ pending.set(src, promise);
25
+ }
26
+ return promise;
27
+ };
28
+
29
+ /**
30
+ * Which of `sources` have loaded. An icon that has an image draws its font icon
31
+ * until this says the image loaded, and keeps the font icon when it fails — the
32
+ * image is never drawn as a broken picture or an empty box.
33
+ *
34
+ * One hook for a whole list, so a component that maps over references (or a
35
+ * `ReqoreMenu`, which clones its children and so cannot hold per-item hooks)
36
+ * asks once.
37
+ */
38
+ export const useLoadedImages = (sources: Array<string | undefined>): ((src?: string) => boolean) => {
39
+ const [, setVersion] = useState(0);
40
+ const key = sources.filter(Boolean).join('\u0000');
41
+
42
+ useEffect(() => {
43
+ let active = true;
44
+ const unknown = Array.from(new Set(sources.filter((src): src is string => !!src))).filter(
45
+ (src) => !imageStatus.has(src)
46
+ );
47
+ unknown.forEach((src) => {
48
+ loadImage(src).then(() => {
49
+ if (active) {
50
+ setVersion((version) => version + 1);
51
+ }
52
+ });
53
+ });
54
+ return () => {
55
+ active = false;
56
+ };
57
+ }, [key]);
58
+
59
+ return (src?: string) => !!src && imageStatus.get(src) === 'loaded';
60
+ };
61
+
62
+ /**
63
+ * Resolves once `src` has loaded or failed — so a test or story can check the
64
+ * settled state instead of waiting an arbitrary time.
65
+ */
66
+ export const whenImageSettled = (src: string): Promise<void> =>
67
+ imageStatus.has(src) ? Promise.resolve() : loadImage(src);
68
+
69
+ /** Forget every image's status. For tests only. */
70
+ export const resetLoadedImagesForTests = (): void => {
71
+ imageStatus.clear();
72
+ pending.clear();
73
+ };
@@ -23,8 +23,11 @@ import { ImageLightbox } from '../imageLightbox';
23
23
  import {
24
24
  defaultInterfaceIcon,
25
25
  IInterfaceReference,
26
+ toInterfaceKindMark,
27
+ TResolveInterfaceIcon,
26
28
  TTicketViewerRole,
27
29
  } from '../supportTicket/meta';
30
+ import { useLoadedImages } from '../supportTicket/useLoadedImages';
28
31
  import type { ITicketThreadAttachment, ITicketThreadMessage } from '../ticketThread/TicketThread';
29
32
 
30
33
  /*
@@ -74,8 +77,13 @@ export interface ITicketReferencesProps {
74
77
  * Omitted ⇒ the action is not offered. It is also omitted per-row for anything filed
75
78
  * against the ticket itself rather than a reply, which has no message to reveal. */
76
79
  onShowInChat?: (messageId: string) => void;
77
- /** resolve a per-kind icon for a referenced interface (the IDE passes its own vocabulary) */
78
- resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
80
+ /** resolve a per-kind icon for a referenced interface (the IDE passes its own
81
+ * vocabulary): a font icon, or a mark `{ icon, image }` whose image is drawn once
82
+ * it loads, with the font icon before that and instead of it if it fails */
83
+ resolveInterfaceIcon?: TResolveInterfaceIcon;
84
+ /** Draw the kind's image ahead of a reference's own logo; see `preferKindImage` on
85
+ * `InterfaceReferenceTags`. Off by default. */
86
+ preferKindImage?: boolean;
79
87
  loading?: boolean;
80
88
  }
81
89
 
@@ -415,6 +423,7 @@ export const TicketReferences = ({
415
423
  onInterfaceClick,
416
424
  onShowInChat,
417
425
  resolveInterfaceIcon,
426
+ preferKindImage = false,
418
427
  loading,
419
428
  }: ITicketReferencesProps) => {
420
429
  // references: ticket-level + every message's, deduped. Rendered as rows carrying
@@ -433,6 +442,17 @@ export const TicketReferences = ({
433
442
  [references, messages]
434
443
  );
435
444
  const resolveIcon = resolveInterfaceIcon ?? defaultInterfaceIcon;
445
+ /* Each reference's icon: the kind's font icon, and the image chosen by the same
446
+ precedence as the chips (`preferKindImage`), drawn once it has loaded. */
447
+ const markOf = (reference: IInterfaceReference) => {
448
+ const mark = toInterfaceKindMark(resolveIcon(reference.interface_kind));
449
+ const own = reference.logo;
450
+ return {
451
+ icon: mark.icon,
452
+ image: preferKindImage ? (mark.image ?? own) : (own ?? mark.image),
453
+ };
454
+ };
455
+ const isImageLoaded = useLoadedImages(allRefs.map((reference) => markOf(reference).image));
436
456
 
437
457
  /**
438
458
  * The trailing controls for a row: its primary action, plus a "three dots" menu when
@@ -768,17 +788,17 @@ export const TicketReferences = ({
768
788
  the group's size reaches every row (ControlGroup/index.tsx:436) */}
769
789
  <ReqoreControlGroup vertical fluid gapSize='small' size='small'>
770
790
  {visibleRefs.map((reference) => {
771
- // an app-triggered qog carries its trigger app's logo; everything else
772
- // shows its kind glyph — the same vocabulary the reference chips use.
773
- const image = reference.logo;
791
+ // the same vocabulary and precedence as the reference chips: the kind's
792
+ // font icon, and the chosen image once it has loaded
793
+ const { icon, image } = markOf(reference);
774
794
  return (
775
795
  <ReqoreEntityRow
776
796
  key={
777
797
  reference.reference_id ??
778
798
  `${reference.interface_kind}:${reference.interface_name}`
779
799
  }
780
- icon={image ? undefined : resolveIcon(reference.interface_kind)}
781
- iconImage={image}
800
+ icon={icon}
801
+ iconImage={isImageLoaded(image) ? image : undefined}
782
802
  label={reference.interface_name}
783
803
  metadata={
784
804
  <ReqoreTextEffect effect={META_EFFECT}>
@@ -21,6 +21,7 @@ import { ImageLightbox } from '../imageLightbox';
21
21
  import {
22
22
  IInterfaceReference,
23
23
  InterfaceReferenceTags,
24
+ TResolveInterfaceIcon,
24
25
  TTicketMessageAuthor,
25
26
  TTicketViewerRole,
26
27
  } from '../supportTicket';
@@ -84,10 +85,15 @@ export interface ITicketThreadProps {
84
85
  * where a consumer hasn't wired it. Non-image attachments never use it.
85
86
  */
86
87
  fetchAttachmentUrl?: (attachmentId: string) => Promise<string>;
87
- /** resolve a per-kind icon for a referenced interface. Consumers with their own
88
- * icon vocabulary (e.g. the qorus-ide IDE) pass one; when omitted, a built-in
88
+ /** resolve a per-kind icon for a referenced interface: a font icon, or a mark
89
+ * `{ icon, image }` whose image (e.g. the Qog logo) is drawn once it loads, with
90
+ * the font icon before that and instead of it if it fails. Consumers with their
91
+ * own icon vocabulary (e.g. the qorus-ide IDE) pass one; when omitted, a built-in
89
92
  * per-kind default is used so every surface shows consistent chips. */
90
- resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
93
+ resolveInterfaceIcon?: TResolveInterfaceIcon;
94
+ /** Draw the kind's image ahead of a reference's own logo on the reference chips;
95
+ * see `preferKindImage` on `InterfaceReferenceTags`. Off by default. */
96
+ preferKindImage?: boolean;
91
97
  /** open a referenced interface; reference chips are static when omitted (e.g. the staff
92
98
  * view, which can't reach the customer's instance, leaves this out). */
93
99
  onInterfaceClick?: (reference: IInterfaceReference) => void;
@@ -557,6 +563,7 @@ export const TicketThread = ({
557
563
  onDownloadAttachment,
558
564
  fetchAttachmentUrl,
559
565
  resolveInterfaceIcon,
566
+ preferKindImage,
560
567
  onInterfaceClick,
561
568
  ticketReferences,
562
569
  }: ITicketThreadProps) => {
@@ -694,6 +701,7 @@ export const TicketThread = ({
694
701
  customTheme={ACCENT}
695
702
  references={refsFor(message)}
696
703
  resolveInterfaceIcon={resolveInterfaceIcon}
704
+ preferKindImage={preferKindImage}
697
705
  onInterfaceClick={onInterfaceClick}
698
706
  />
699
707
  </div>
@@ -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