@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.
- package/design/FORM_ENGINE_COMPACT.md +23 -5
- package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
- package/dist/components/form/engine/CompactToolbar.js +2 -2
- package/dist/components/form/engine/CompactToolbar.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +71 -62
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.js +6 -3
- package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts +16 -20
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +16 -49
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -1
- package/dist/components/form/engine/variants/VariantCalmTable.js +1 -1
- package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -1
- package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -1
- package/dist/components/form/engine/variants/variantModel.js +8 -3
- package/dist/components/form/engine/variants/variantModel.js.map +1 -1
- package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
- package/dist/components/form/expressions/builder/index.js +22 -13
- package/dist/components/form/expressions/builder/index.js.map +1 -1
- package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
- package/dist/components/form/fields/array/ArrayAuto.js +8 -0
- package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
- package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
- package/dist/components/form/fields/array/ArrayAutoField.js +8 -0
- package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.js +4 -1
- package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
- package/dist/components/form/fields/binary/Binary.d.ts.map +1 -1
- package/dist/components/form/fields/binary/Binary.js +10 -0
- package/dist/components/form/fields/binary/Binary.js.map +1 -1
- package/dist/components/form/fields/emittedValues.d.ts +38 -0
- package/dist/components/form/fields/emittedValues.d.ts.map +1 -0
- package/dist/components/form/fields/emittedValues.js +67 -0
- package/dist/components/form/fields/emittedValues.js.map +1 -0
- package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
- package/dist/components/form/fields/long-string/LongString.js +24 -8
- package/dist/components/form/fields/long-string/LongString.js.map +1 -1
- package/dist/components/form/fields/number/Number.d.ts.map +1 -1
- package/dist/components/form/fields/number/Number.js +9 -0
- package/dist/components/form/fields/number/Number.js.map +1 -1
- package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
- package/dist/components/form/fields/rich-text/RichText.js +10 -0
- package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
- package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
- package/dist/components/form/fields/template/TemplateField.js +3 -1
- package/dist/components/form/fields/template/TemplateField.js.map +1 -1
- package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
- package/dist/components/smartEditor/useLspSession.js +8 -0
- package/dist/components/smartEditor/useLspSession.js.map +1 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +21 -5
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.js +24 -8
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.d.ts +5 -4
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.js +27 -2
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
- package/dist/components/supportTicket/meta.d.ts +16 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -1
- package/dist/components/supportTicket/meta.js +6 -1
- package/dist/components/supportTicket/meta.js.map +1 -1
- package/dist/components/supportTicket/useLoadedImages.d.ts +18 -0
- package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -0
- package/dist/components/supportTicket/useLoadedImages.js +72 -0
- package/dist/components/supportTicket/useLoadedImages.js.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts +9 -5
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
- package/dist/components/ticketReferences/TicketReferences.js +22 -9
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
- package/dist/components/ticketThread/TicketThread.d.ts +10 -6
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
- package/dist/components/ticketThread/TicketThread.js +2 -2
- package/dist/components/ticketThread/TicketThread.js.map +1 -1
- package/dist/helpers/allowedValues.d.ts +71 -0
- package/dist/helpers/allowedValues.d.ts.map +1 -0
- package/dist/helpers/allowedValues.js +183 -0
- package/dist/helpers/allowedValues.js.map +1 -0
- package/dist/helpers/common.d.ts +7 -0
- package/dist/helpers/common.d.ts.map +1 -1
- package/dist/helpers/common.js +16 -1
- package/dist/helpers/common.js.map +1 -1
- package/dist/helpers/expressions.d.ts +13 -0
- package/dist/helpers/expressions.d.ts.map +1 -1
- package/dist/helpers/expressions.js +23 -1
- package/dist/helpers/expressions.js.map +1 -1
- package/dist/helpers/validations.d.ts.map +1 -1
- package/dist/helpers/validations.js +14 -0
- package/dist/helpers/validations.js.map +1 -1
- package/dist/utils/fetch.d.ts +4 -0
- package/dist/utils/fetch.d.ts.map +1 -1
- package/dist/utils/fetch.js +6 -1
- package/dist/utils/fetch.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactToolbar.tsx +8 -3
- package/src/components/form/engine/FormEngine.stories.tsx +206 -13
- package/src/components/form/engine/FormEngine.tsx +70 -57
- package/src/components/form/engine/OptionFieldMessages.tsx +7 -5
- package/src/components/form/engine/readFirst.ts +19 -50
- package/src/components/form/engine/variants/VariantCalmTable.tsx +3 -1
- package/src/components/form/engine/variants/variantModel.ts +10 -3
- package/src/components/form/expressions/builder/index.tsx +15 -2
- package/src/components/form/fields/array/ArrayAuto.tsx +10 -1
- package/src/components/form/fields/array/ArrayAutoField.tsx +10 -1
- package/src/components/form/fields/auto/AutoFormField.tsx +4 -1
- package/src/components/form/fields/binary/Binary.tsx +10 -0
- package/src/components/form/fields/emittedValues.ts +62 -0
- package/src/components/form/fields/long-string/LongString.tsx +28 -8
- package/src/components/form/fields/number/Number.tsx +9 -0
- package/src/components/form/fields/rich-text/RichText.tsx +10 -0
- package/src/components/form/fields/template/TemplateField.tsx +4 -1
- package/src/components/smartEditor/useLspSession.ts +8 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +86 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +44 -11
- package/src/components/supportTicket/ReferencePicker.stories.tsx +65 -0
- package/src/components/supportTicket/ReferencePicker.tsx +26 -5
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +5 -2
- package/src/components/supportTicket/__fixtures__/kindMarks.ts +28 -0
- package/src/components/supportTicket/meta.ts +21 -0
- package/src/components/supportTicket/useLoadedImages.ts +73 -0
- package/src/components/ticketReferences/TicketReferences.tsx +27 -7
- package/src/components/ticketThread/TicketThread.tsx +11 -3
- package/src/helpers/allowedValues.ts +216 -0
- package/src/helpers/common.ts +14 -0
- package/src/helpers/expressions.ts +25 -0
- package/src/helpers/validations.ts +16 -0
- 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
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
//
|
|
772
|
-
//
|
|
773
|
-
const image = reference
|
|
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={
|
|
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
|
|
88
|
-
* icon
|
|
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?:
|
|
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
|
+
};
|
package/src/helpers/common.ts
CHANGED
|
@@ -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
|