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

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 (86) 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 +35 -8
  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/variants/VariantCalmTable.d.ts.map +1 -1
  12. package/dist/components/form/engine/variants/VariantCalmTable.js +1 -1
  13. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -1
  14. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -1
  15. package/dist/components/form/engine/variants/variantModel.js +8 -3
  16. package/dist/components/form/engine/variants/variantModel.js.map +1 -1
  17. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  18. package/dist/components/form/fields/array/ArrayAuto.js +8 -0
  19. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  20. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  21. package/dist/components/form/fields/array/ArrayAutoField.js +8 -0
  22. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  23. package/dist/components/form/fields/binary/Binary.d.ts.map +1 -1
  24. package/dist/components/form/fields/binary/Binary.js +10 -0
  25. package/dist/components/form/fields/binary/Binary.js.map +1 -1
  26. package/dist/components/form/fields/emittedValues.d.ts +38 -0
  27. package/dist/components/form/fields/emittedValues.d.ts.map +1 -0
  28. package/dist/components/form/fields/emittedValues.js +67 -0
  29. package/dist/components/form/fields/emittedValues.js.map +1 -0
  30. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  31. package/dist/components/form/fields/long-string/LongString.js +24 -8
  32. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  33. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  34. package/dist/components/form/fields/number/Number.js +9 -0
  35. package/dist/components/form/fields/number/Number.js.map +1 -1
  36. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  37. package/dist/components/form/fields/rich-text/RichText.js +10 -0
  38. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  39. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +21 -5
  40. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  41. package/dist/components/supportTicket/InterfaceReferenceTags.js +24 -8
  42. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  43. package/dist/components/supportTicket/ReferencePicker.d.ts +5 -4
  44. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  45. package/dist/components/supportTicket/ReferencePicker.js +27 -2
  46. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  47. package/dist/components/supportTicket/meta.d.ts +16 -0
  48. package/dist/components/supportTicket/meta.d.ts.map +1 -1
  49. package/dist/components/supportTicket/meta.js +6 -1
  50. package/dist/components/supportTicket/meta.js.map +1 -1
  51. package/dist/components/supportTicket/useLoadedImages.d.ts +18 -0
  52. package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -0
  53. package/dist/components/supportTicket/useLoadedImages.js +72 -0
  54. package/dist/components/supportTicket/useLoadedImages.js.map +1 -0
  55. package/dist/components/ticketReferences/TicketReferences.d.ts +9 -5
  56. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  57. package/dist/components/ticketReferences/TicketReferences.js +22 -9
  58. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  59. package/dist/components/ticketThread/TicketThread.d.ts +10 -6
  60. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  61. package/dist/components/ticketThread/TicketThread.js +2 -2
  62. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/components/form/engine/CompactToolbar.tsx +8 -3
  65. package/src/components/form/engine/FormEngine.stories.tsx +206 -13
  66. package/src/components/form/engine/FormEngine.tsx +38 -8
  67. package/src/components/form/engine/OptionFieldMessages.tsx +7 -5
  68. package/src/components/form/engine/variants/VariantCalmTable.tsx +3 -1
  69. package/src/components/form/engine/variants/variantModel.ts +10 -3
  70. package/src/components/form/fields/array/ArrayAuto.tsx +10 -1
  71. package/src/components/form/fields/array/ArrayAutoField.tsx +10 -1
  72. package/src/components/form/fields/binary/Binary.tsx +10 -0
  73. package/src/components/form/fields/emittedValues.ts +62 -0
  74. package/src/components/form/fields/long-string/LongString.tsx +28 -8
  75. package/src/components/form/fields/number/Number.tsx +9 -0
  76. package/src/components/form/fields/rich-text/RichText.tsx +10 -0
  77. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +86 -0
  78. package/src/components/supportTicket/InterfaceReferenceTags.tsx +44 -11
  79. package/src/components/supportTicket/ReferencePicker.stories.tsx +51 -0
  80. package/src/components/supportTicket/ReferencePicker.tsx +26 -5
  81. package/src/components/supportTicket/TicketReplyBox.stories.tsx +5 -2
  82. package/src/components/supportTicket/__fixtures__/kindMarks.ts +28 -0
  83. package/src/components/supportTicket/meta.ts +21 -0
  84. package/src/components/supportTicket/useLoadedImages.ts +73 -0
  85. package/src/components/ticketReferences/TicketReferences.tsx +27 -7
  86. package/src/components/ticketThread/TicketThread.tsx +11 -3
@@ -1,17 +1,24 @@
1
1
  import { ReqoreControlGroup, ReqoreP, ReqoreTag } from '@qoretechnologies/reqore';
2
2
  import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
3
- import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
4
3
  import { ComponentProps } from 'react';
5
- import { defaultInterfaceIcon, IInterfaceReference } from './meta';
4
+ import {
5
+ defaultInterfaceIcon,
6
+ IInterfaceReference,
7
+ toInterfaceKindMark,
8
+ TResolveInterfaceIcon,
9
+ } from './meta';
10
+ import { useLoadedImages } from './useLoadedImages';
6
11
 
7
12
  export interface IInterfaceReferenceTagsProps {
8
13
  references?: IInterfaceReference[];
9
14
  /** Optional leading label, e.g. "References" in a ticket header. Omitted (the
10
15
  * default) inside the thread, where the chips sit under a message. */
11
16
  label?: string;
12
- /** Resolve a per-kind icon. Consumers with their own icon vocabulary (the IDE)
17
+ /** Resolve a per-kind icon: a font icon, or a mark `{ icon, image }` whose image
18
+ * (e.g. the Qog logo) is drawn once it loads, with the font icon before that and
19
+ * instead of it if it fails. Consumers with their own icon vocabulary (the IDE)
13
20
  * pass one; when omitted the built-in per-kind default is used. */
14
- resolveInterfaceIcon?: (kind: string) => IReqoreIconName;
21
+ resolveInterfaceIcon?: TResolveInterfaceIcon;
15
22
  /** Open a referenced interface; chips are static (informational) when omitted —
16
23
  * e.g. the staff view, which can't reach the customer's instance. */
17
24
  onInterfaceClick?: (reference: IInterfaceReference) => void;
@@ -32,6 +39,21 @@ export interface IInterfaceReferenceTagsProps {
32
39
  * qogs list does. When this (or the reference's own `logo`) returns a value, the
33
40
  * chip shows the image in place of the kind icon. */
34
41
  resolveInterfaceImage?: (reference: IInterfaceReference) => string | undefined;
42
+ /**
43
+ * Which image a chip draws when there is more than one:
44
+ *
45
+ * - `false` (the default): the reference's own image first — its stored `logo`,
46
+ * then `resolveInterfaceImage` — and the kind's image (from
47
+ * `resolveInterfaceIcon`) only when it has neither. This is how chips have
48
+ * always looked: an app-triggered Qog shows its trigger app's logo.
49
+ * - `true`: the kind's image first, then the reference's own. For a host that
50
+ * draws every interface of a kind with one mark (the IDE's green Qog), so a
51
+ * Qog reads as a Qog whatever app triggers it.
52
+ *
53
+ * Either way the kind's font icon shows until the chosen image loads, and
54
+ * instead of it if it cannot.
55
+ */
56
+ preferKindImage?: boolean;
35
57
  }
36
58
 
37
59
  /**
@@ -51,11 +73,22 @@ export const InterfaceReferenceTags = ({
51
73
  intent,
52
74
  customTheme,
53
75
  resolveInterfaceImage,
76
+ preferKindImage = false,
54
77
  }: IInterfaceReferenceTagsProps) => {
78
+ const resolveIcon = resolveInterfaceIcon ?? defaultInterfaceIcon;
79
+ const marks = (references ?? []).map((reference) => {
80
+ const mark = toInterfaceKindMark(resolveIcon(reference.interface_kind));
81
+ const own = reference.logo ?? resolveInterfaceImage?.(reference);
82
+ return {
83
+ reference,
84
+ icon: mark.icon,
85
+ image: preferKindImage ? (mark.image ?? own) : (own ?? mark.image),
86
+ };
87
+ });
88
+ const isLoaded = useLoadedImages(marks.map(({ image }) => image));
55
89
  if (!references?.length) {
56
90
  return null;
57
91
  }
58
- const resolveIcon = resolveInterfaceIcon ?? defaultInterfaceIcon;
59
92
  return (
60
93
  <ReqoreControlGroup verticalAlign='center' wrap gapSize='small'>
61
94
  {label ? (
@@ -63,18 +96,18 @@ export const InterfaceReferenceTags = ({
63
96
  {label}
64
97
  </ReqoreP>
65
98
  ) : null}
66
- {references.map((reference) => {
67
- // an app-triggered qog shows the trigger app's logo (stored, or resolved by
68
- // a viewer with the app catalogue); everything else shows its kind glyph.
69
- const image = reference.logo ?? resolveInterfaceImage?.(reference);
99
+ {marks.map(({ reference, icon, image }) => {
100
+ // The chosen image (see `preferKindImage`) once it has loaded; the kind's
101
+ // font icon until then, and if it fails.
102
+ const loaded = isLoaded(image);
70
103
  return (
71
104
  <ReqoreTag
72
105
  key={
73
106
  reference.reference_id ??
74
107
  `${reference.interface_kind}:${reference.interface_name}`
75
108
  }
76
- icon={image ? undefined : resolveIcon(reference.interface_kind)}
77
- leftIconProps={image ? { image } : undefined}
109
+ icon={icon}
110
+ leftIconProps={loaded ? { image } : undefined}
78
111
  labelKey={reference.interface_kind}
79
112
  label={reference.interface_name}
80
113
  size={size}
@@ -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,47 @@ export const AboveTheComposer: Story = {
517
524
  await expect(canvas.getAllByText('order-sync:1.2').length).toBeGreaterThan(0);
518
525
  },
519
526
  };
527
+
528
+ const menuImages = (canvasElement: HTMLElement) =>
529
+ Array.from(canvasElement.querySelectorAll('.reqore-menu-item img')).map((img) =>
530
+ img.getAttribute('src')
531
+ );
532
+
533
+ export const KindImage: Story = {
534
+ parameters: {
535
+ docs: {
536
+ description: {
537
+ story:
538
+ '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.',
539
+ },
540
+ },
541
+ },
542
+ decorators: [darkTheme],
543
+ render: () => <Hosted initialKind='qog' resolveInterfaceIcon={storyKindMark} />,
544
+ async play({ canvasElement }) {
545
+ // the Qogs kind row plus its four interfaces
546
+ await waitFor(() =>
547
+ expect(menuImages(canvasElement).filter((src) => src === STORY_QOG_IMAGE)).toHaveLength(5)
548
+ );
549
+ },
550
+ };
551
+
552
+ export const KindImageFailsToLoad: Story = {
553
+ parameters: {
554
+ docs: {
555
+ description: {
556
+ story:
557
+ '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.',
558
+ },
559
+ },
560
+ },
561
+ decorators: [darkTheme],
562
+ render: () => <Hosted initialKind='qog' resolveInterfaceIcon={storyBrokenKindMark} />,
563
+ async play({ canvasElement }) {
564
+ const canvas = within(canvasElement);
565
+ await canvas.findByRole('button', { name: 'telegram-intake' });
566
+ // Once the load has failed, no picture is drawn.
567
+ await whenImageSettled(STORY_BROKEN_IMAGE);
568
+ await expect(menuImages(canvasElement)).toEqual([]);
569
+ },
570
+ };
@@ -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>