@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
@@ -20,6 +20,7 @@ import {
20
20
  isOptionValueEmpty,
21
21
  } from '../readFirst';
22
22
  import { isValueTemplate } from '../../../../helpers/templates';
23
+ import { hasAllDependenciesFullfilled } from '../../../../helpers/validations';
23
24
 
24
25
  export type TVariantStatus =
25
26
  | 'set' // has a valid value
@@ -153,8 +154,13 @@ export function buildVariantGroups(
153
154
  const empty = isOptionValueEmpty(field?.value);
154
155
  const ownGroups: string[] = schema?.required_groups || [];
155
156
  // Member of a one-of group that nobody has satisfied yet.
156
- const inUnmetGroup = ownGroups.some((k) => reqGroups[k] && !reqGroups[k].satisfied);
157
- const required = !!schema?.required;
157
+ // A field locked by an unmet `depends_on` does not apply yet, so it is
158
+ // neither required nor part of an unmet group — the engine's rule.
159
+ const locked =
160
+ !!schema?.depends_on && !hasAllDependenciesFullfilled(schema.depends_on, values, options);
161
+ const inUnmetGroup =
162
+ !locked && ownGroups.some((k) => reqGroups[k] && !reqGroups[k].satisfied);
163
+ const required = !locked && !!schema?.required;
158
164
 
159
165
  let status: TVariantStatus;
160
166
  let reason: string | undefined;
@@ -183,7 +189,8 @@ export function buildVariantGroups(
183
189
  label: (schema?.display_name as string) || name,
184
190
  shortDesc: schema?.short_desc,
185
191
  longDesc: schema?.desc,
186
- required: required || ownGroups.length > 0,
192
+ // The asterisk still marks it: it is required once it applies.
193
+ required: !!schema?.required || ownGroups.length > 0,
187
194
  readOnly: !!schema?.readonly,
188
195
  status,
189
196
  reason,
@@ -24,7 +24,11 @@ import { darken, rgba } from 'polished';
24
24
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
25
25
  import styled from 'styled-components';
26
26
  import { moveItem } from '../../../../helpers/common';
27
- import { areQorusTypesCompatible, getArgumentType } from '../../../../helpers/expressions';
27
+ import {
28
+ areQorusTypesCompatible,
29
+ expressionFitsReturnType,
30
+ getArgumentType,
31
+ } from '../../../../helpers/expressions';
28
32
  import { addMissingExpressionArgs } from '../argumentPresence';
29
33
  import { findTemplate } from '../../../../helpers/templates';
30
34
  import { validateField, validateFieldWithResult } from '../../../../helpers/validations';
@@ -594,6 +598,15 @@ export const Expression = ({
594
598
  const defaultItems = useMemo(
595
599
  () =>
596
600
  expressions.value
601
+ // a condition (a field or an operand that must be true or false) offers the operations that
602
+ // give true or false; a group's members are its own business, and the operation already chosen
603
+ // stays, so a stored mismatch can be seen and fixed
604
+ .filter(
605
+ (exp) =>
606
+ isChild ||
607
+ exp.name === value?.value?.exp ||
608
+ expressionFitsReturnType(returnType as string | string[] | undefined, exp)
609
+ )
597
610
  .map((exp) => ({
598
611
  name: exp.name,
599
612
  value: exp.name,
@@ -612,7 +625,7 @@ export const Expression = ({
612
625
  ? !exp.from_server
613
626
  : true
614
627
  ),
615
- [JSON.stringify(expressions.value), serverExpression, level]
628
+ [JSON.stringify(expressions.value), serverExpression, level, isChild, returnType, value?.value?.exp]
616
629
  );
617
630
 
618
631
  // SEAM (reqraft): reqraft's `SelectFormField` calls `onChange(value)` with
@@ -17,9 +17,10 @@ import {
17
17
  ReqoreVerticalSpacer,
18
18
  useReqoreProperty,
19
19
  } from '@qoretechnologies/reqore';
20
- import { map, size } from 'lodash';
20
+ import { isEqual, map, size } from 'lodash';
21
21
  import { useEffect, useState } from 'react';
22
22
  import { useDebounce } from 'react-use';
23
+ import { useEmittedValues } from '../emittedValues';
23
24
  import { validateFieldWithResult } from '../../../../helpers/validations';
24
25
  import { useWhyDidYouUpdate } from '../../../../hooks/useWhyDidYouUpdate';
25
26
  // Direct imports — the cycle (ArrayAuto → TemplateField/auto → ArrayAuto) is
@@ -81,12 +82,20 @@ export const ArrayAuto = ({
81
82
  ...rest,
82
83
  });
83
84
 
85
+ // The parent's echo of an emit still in flight is not a change: copying it
86
+ // over the local list lost what was edited since. See `EmittedValues`.
87
+ const emitted = useEmittedValues<unknown>(isEqual);
88
+
84
89
  useEffect(() => {
90
+ if (emitted.isEcho(value)) {
91
+ return;
92
+ }
85
93
  setLocalValue(value);
86
94
  }, [JSON.stringify(value)]);
87
95
 
88
96
  useDebounce(
89
97
  () => {
98
+ emitted.record(localValue);
90
99
  onChange?.(name, localValue);
91
100
  },
92
101
  300,
@@ -8,10 +8,11 @@ import {
8
8
  useReqoreProperty,
9
9
  } from '@qoretechnologies/reqore';
10
10
  import { IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
11
- import { map, size } from 'lodash';
11
+ import { isEqual, map, size } from 'lodash';
12
12
  import { recordIdentity } from '../../engine/readFirst';
13
13
  import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
14
14
  import { useDebounce } from 'react-use';
15
+ import { useEmittedValues } from '../emittedValues';
15
16
  import { validateFieldWithResult } from '../../../../helpers/validations';
16
17
 
17
18
  export interface IArrayAutoFieldProps {
@@ -77,12 +78,20 @@ export const ArrayAutoField = memo(
77
78
  const [editingIndex, setEditingIndex] = useState<number | null>(null);
78
79
  const inputKeyRef = useRef(0);
79
80
 
81
+ // The parent's echo of an emit still in flight is not a change: copying it
82
+ // over the local list lost what was edited since. See `EmittedValues`.
83
+ const emitted = useEmittedValues<unknown>(isEqual);
84
+
80
85
  useEffect(() => {
86
+ if (emitted.isEcho(value)) {
87
+ return;
88
+ }
81
89
  setLocalValue(value);
82
90
  }, [JSON.stringify(value)]);
83
91
 
84
92
  useDebounce(
85
93
  () => {
94
+ emitted.record(localValue);
86
95
  onChange(name, localValue);
87
96
  },
88
97
  300,
@@ -819,9 +819,12 @@ function AutoField<T = any>({
819
819
  );
820
820
  }
821
821
 
822
+ // the multi-select reads each item's `{type, value}` envelope itself, as Field.tsx gives it;
823
+ // handing it the unwrapped values left every choice `undefined`, so a pick stored an empty
824
+ // element and drew an "undefined" chip
822
825
  return (
823
826
  <MultiSelectFormField
824
- items={mappedAllowedValues}
827
+ items={rest.element_allowed_values}
825
828
  value={formatFromServerValue(value)}
826
829
  onChange={(selected) => {
827
830
  handleChange(name, formatToServerValue(selected));
@@ -2,6 +2,7 @@ import { ReqoreControlGroup, ReqoreTextarea } from '@qoretechnologies/reqore';
2
2
  import { IReqoreTextareaProps } from '@qoretechnologies/reqore/dist/components/Textarea';
3
3
  import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
4
4
  import { useDebounce } from 'react-use';
5
+ import { useEmittedValues } from '../emittedValues';
5
6
  import { IFileFormFieldValue, ReqraftFileFormField } from '../file/File';
6
7
 
7
8
  export interface IReqraftBinaryFormFieldProps extends Omit<IReqoreTextareaProps, 'onChange' | 'value'> {
@@ -28,8 +29,14 @@ const stripDataUrlPrefix = (dataUrl: string): string => {
28
29
  export const ReqraftBinaryFormField = memo(
29
30
  ({ value, onChange, ...rest }: IReqraftBinaryFormFieldProps) => {
30
31
  const [localValue, setLocalValue] = useState<string>(value ?? '');
32
+ // The parent's echo of an emit still in flight is not a change: copying it
33
+ // over the local value lost what was typed since. See `EmittedValues`.
34
+ const emitted = useEmittedValues<string | undefined>();
31
35
 
32
36
  useEffect(() => {
37
+ if (emitted.isEcho(value)) {
38
+ return;
39
+ }
33
40
  if (value !== localValue) {
34
41
  setLocalValue(value ?? '');
35
42
  }
@@ -38,6 +45,7 @@ export const ReqraftBinaryFormField = memo(
38
45
  useDebounce(
39
46
  () => {
40
47
  if (localValue !== value) {
48
+ emitted.record(localValue);
41
49
  onChange?.(localValue);
42
50
  }
43
51
  },
@@ -53,6 +61,7 @@ export const ReqraftBinaryFormField = memo(
53
61
  (file?: IFileFormFieldValue) => {
54
62
  const base64 = stripDataUrlPrefix(file?.content ?? '');
55
63
  setLocalValue(base64);
64
+ emitted.record(base64);
56
65
  onChange?.(base64);
57
66
  },
58
67
  [onChange]
@@ -67,6 +76,7 @@ export const ReqraftBinaryFormField = memo(
67
76
  onChange={handleChange}
68
77
  onClearClick={() => {
69
78
  setLocalValue('');
79
+ emitted.record('');
70
80
  onChange?.('');
71
81
  }}
72
82
  placeholder='Base64-encoded binary value'
@@ -0,0 +1,62 @@
1
+ import { useRef } from 'react';
2
+
3
+ /**
4
+ * What a debounced field has told its parent and not yet heard back.
5
+ *
6
+ * A field that keeps a local copy of what is being typed reports it after a
7
+ * short pause, and the parent hands that value back down as the `value` prop —
8
+ * usually after its OWN debounce, so the echo of an emit arrives while the
9
+ * person is already typing the next character. Treating every incoming value
10
+ * as an external change copied that stale echo over the newer text: typing
11
+ * "ship-order" with 150 ms between keys left "shp-order".
12
+ *
13
+ * So an incoming value is first checked against what is in flight. One that
14
+ * matches an emit is the parent catching up, not a change, and it acknowledges
15
+ * that emit and every one before it (a parent that debounces skips some).
16
+ * Anything else is a genuine change from outside and replaces the local text;
17
+ * it also drops whatever was in flight, since the parent has moved past it.
18
+ *
19
+ * A genuine external change that happens to equal an emit still in flight is
20
+ * indistinguishable from its echo, and is treated as one — harmless, because it
21
+ * sets the very value the field already reported.
22
+ */
23
+ export class EmittedValues<T> {
24
+ private pending: T[] = [];
25
+
26
+ /** Bounded: a parent that never feeds its value back never acknowledges. */
27
+ private static readonly LIMIT = 50;
28
+
29
+ constructor(private readonly equals: (a: T, b: T) => boolean = Object.is) {}
30
+
31
+ /** Record a value just reported to the parent. */
32
+ record(value: T): void {
33
+ this.pending.push(value);
34
+ if (this.pending.length > EmittedValues.LIMIT) {
35
+ this.pending.shift();
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Whether `incoming` is the echo of an emit still in flight. An echo
41
+ * acknowledges that emit and every earlier one; anything else clears the
42
+ * in-flight list, because the parent has a value of its own.
43
+ */
44
+ isEcho(incoming: T): boolean {
45
+ const index = this.pending.findIndex((value) => this.equals(value, incoming));
46
+ if (index === -1) {
47
+ this.pending = [];
48
+ return false;
49
+ }
50
+ this.pending = this.pending.slice(index + 1);
51
+ return true;
52
+ }
53
+ }
54
+
55
+ /** One `EmittedValues` for the life of the component. */
56
+ export const useEmittedValues = <T>(equals?: (a: T, b: T) => boolean): EmittedValues<T> => {
57
+ const ref = useRef<EmittedValues<T>>();
58
+ if (!ref.current) {
59
+ ref.current = new EmittedValues<T>(equals);
60
+ }
61
+ return ref.current;
62
+ };
@@ -7,6 +7,7 @@ import {
7
7
  hasLineBreak,
8
8
  isSingleLineStringType,
9
9
  } from '../../../../helpers/singleLineString';
10
+ import { useEmittedValues } from '../emittedValues';
10
11
 
11
12
  export interface ILongStringFormFieldProps extends Omit<IReqoreTextareaProps, 'onChange'> {
12
13
  value?: string;
@@ -38,24 +39,41 @@ export const LongStringFormField = ({
38
39
  and reporting that difference marked every form holding an empty text
39
40
  field as changed before anyone typed. */
40
41
  const lastEmittedRef = useRef<string>(value ?? '');
42
+ /* The emits the parent has not handed back yet. Its echo of an earlier one
43
+ arrives while the next keys are being typed, and copying it over the text
44
+ lost them ("ship-order" typed 150 ms apart became "shp-order"). An echo is
45
+ the parent catching up, not a change; see `EmittedValues`. */
46
+ const emitted = useEmittedValues<string>();
47
+
48
+ const emit = useCallback(
49
+ (next: string) => {
50
+ lastEmittedRef.current = next;
51
+ emitted.record(next);
52
+ onChange?.(next);
53
+ },
54
+ [onChange, emitted]
55
+ );
41
56
 
42
57
  useEffect(() => {
43
- if ((value ?? '') !== localValue) {
44
- setLocalValue(value ?? '');
58
+ const incoming = value ?? '';
59
+ if (emitted.isEcho(incoming)) {
60
+ return;
61
+ }
62
+ if (incoming !== localValue) {
63
+ setLocalValue(incoming);
45
64
  }
46
65
  // A value from outside is not something to report back to whoever sent it.
47
- lastEmittedRef.current = value ?? '';
66
+ lastEmittedRef.current = incoming;
48
67
  }, [value]);
49
68
 
50
69
  useDebounce(
51
70
  () => {
52
71
  if (localValue !== lastEmittedRef.current) {
53
- lastEmittedRef.current = localValue;
54
- onChange?.(localValue);
72
+ emit(localValue);
55
73
  }
56
74
  },
57
75
  100,
58
- [localValue, onChange]
76
+ [localValue, emit]
59
77
  );
60
78
 
61
79
  const singleLine = isSingleLineStringType(type);
@@ -87,9 +105,11 @@ export const LongStringFormField = ({
87
105
 
88
106
  const handleClearClick = useCallback(() => {
89
107
  setLocalValue('');
90
- onChange?.('');
108
+ // Reported at once, and recorded like any emit, so the debounce does not
109
+ // report it a second time and its echo is not mistaken for a change.
110
+ emit('');
91
111
  onClearClick?.();
92
- }, [onChange, onClearClick]);
112
+ }, [emit, onClearClick]);
93
113
 
94
114
  return (
95
115
  <ReqoreTextarea
@@ -3,6 +3,7 @@ import { IReqoreInputProps } from '@qoretechnologies/reqore/dist/components/Inpu
3
3
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
4
4
  import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
5
5
  import { useDebounce } from 'react-use';
6
+ import { useEmittedValues } from '../emittedValues';
6
7
  // Const-only usage at render time — the cycle (Number → TemplateField → Number)
7
8
  // is safe the same way Field → AutoFormField → Field is.
8
9
  import { TemplatesListProps } from '../template/TemplateField';
@@ -37,8 +38,14 @@ export const NumberFormField = ({
37
38
  ...rest
38
39
  }: INumberFormFieldProps) => {
39
40
  const [localValue, setLocalValue] = useState<number | string>(value ?? '');
41
+ // The parent's echo of an emit still in flight is not a change: copying it
42
+ // over the local value lost what was typed since. See `EmittedValues`.
43
+ const emitted = useEmittedValues<number | string | undefined>();
40
44
 
41
45
  useEffect(() => {
46
+ if (emitted.isEcho(value)) {
47
+ return;
48
+ }
42
49
  if (value !== localValue) {
43
50
  setLocalValue(value ?? '');
44
51
  }
@@ -47,6 +54,7 @@ export const NumberFormField = ({
47
54
  useDebounce(
48
55
  () => {
49
56
  if (localValue !== value) {
57
+ emitted.record(localValue);
50
58
  onChange?.(localValue);
51
59
  }
52
60
  },
@@ -75,6 +83,7 @@ export const NumberFormField = ({
75
83
 
76
84
  const handleResetClick = useCallback((): void => {
77
85
  setLocalValue(0);
86
+ emitted.record(0);
78
87
  onChange?.(0);
79
88
  }, [onChange]);
80
89
 
@@ -8,6 +8,7 @@ import { IReqoreTooltip } from '@qoretechnologies/reqore/dist/types/global';
8
8
  import { isEqual, size } from 'lodash';
9
9
  import { KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
10
  import { useDebounce } from 'react-use';
11
+ import { useEmittedValues } from '../emittedValues';
11
12
  import { flattenToSingleLine, hasLineBreak } from '../../../../helpers/singleLineString';
12
13
  import {
13
14
  renderTemplateItemDescriptions,
@@ -129,8 +130,15 @@ export const RichTextFormField = memo(({
129
130
  document from it would hand the editor a new tree on every keystroke —
130
131
  which it answers by replacing its content and moving the cursor to the end. */
131
132
  const lastTextRef = useRef(isText ? String(value ?? '') : '');
133
+ /* The parent's echo of an emit still in flight is not a change. It arrives
134
+ while the next keys are typed, and rebuilding the document from it threw
135
+ them away. See `EmittedValues`. */
136
+ const emitted = useEmittedValues<unknown>(isEqual);
132
137
 
133
138
  useEffect(() => {
139
+ if (emitted.isEcho(isText ? String(value ?? '') : value)) {
140
+ return;
141
+ }
134
142
  if (isText) {
135
143
  const text = String(value ?? '');
136
144
  if (text !== lastTextRef.current) {
@@ -148,11 +156,13 @@ export const RichTextFormField = memo(({
148
156
  () => {
149
157
  if (isText) {
150
158
  if (lastTextRef.current !== String(value ?? '')) {
159
+ emitted.record(lastTextRef.current);
151
160
  onChange?.(lastTextRef.current);
152
161
  }
153
162
  return;
154
163
  }
155
164
  if (!isEqual(localValue, value)) {
165
+ emitted.record(localValue);
156
166
  onChange?.(localValue);
157
167
  }
158
168
  },
@@ -803,7 +803,10 @@ const TemplateFieldImpl = memo(
803
803
  }
804
804
  }, [JSON.stringify(templateValue)]);
805
805
 
806
- const showTemplateToggle = allowCustomValues && allowTemplates && !rest.arg_schema;
806
+ // an editor that takes templates into its own text has no template mode to switch to: "Use Template"
807
+ // there emptied the field and showed nothing in its place
808
+ const showTemplateToggle =
809
+ allowCustomValues && allowTemplates && !rest.arg_schema && !editorHandlesTemplates;
807
810
 
808
811
  // Only a BRACED context ref (`$data:{…}` — machine-written, nobody types
809
812
  // one) renders as the picker chip (named via `resolveTemplateLabel`)
@@ -132,6 +132,8 @@ export function useLspSession(
132
132
 
133
133
  c.onReady(setIsReady);
134
134
 
135
+ // the session ends when the editor goes: a connection it closes itself has not failed
136
+ let disposed = false;
135
137
  c.connect()
136
138
  .then(() => {
137
139
  // Initialize completed — mirror the captured server
@@ -152,12 +154,18 @@ export function useLspSession(
152
154
  setIsReady(true);
153
155
  })
154
156
  .catch((err) => {
157
+ if (disposed) {
158
+ // the editor went before the connection opened (Text mode left again, a form closed): it was
159
+ // closed on purpose, which is not a failure to report
160
+ return;
161
+ }
155
162
  // eslint-disable-next-line no-console
156
163
  console.error('[LSP session] Connect failed:', err);
157
164
  setIsReady(false);
158
165
  });
159
166
 
160
167
  return () => {
168
+ disposed = true;
161
169
  c.disconnect();
162
170
  clientRef.current = null;
163
171
  serverTextRef.current = null;
@@ -3,6 +3,14 @@ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
3
3
  import { StoryMeta } from '../../types';
4
4
  import { InterfaceReferenceTags } from './InterfaceReferenceTags';
5
5
  import { IInterfaceReference } from './meta';
6
+ import {
7
+ STORY_APP_LOGO,
8
+ STORY_BROKEN_IMAGE,
9
+ STORY_QOG_IMAGE,
10
+ storyBrokenKindMark,
11
+ storyKindMark,
12
+ } from './__fixtures__/kindMarks';
13
+ import { whenImageSettled } from './useLoadedImages';
6
14
 
7
15
  // Several references, including two of the same kind (workflow), as `{kind, name}`
8
16
  // snapshots — no `resolveInterfaceIcon` is passed, so the built-in per-kind default
@@ -169,3 +177,81 @@ export const Empty: Story = {
169
177
  await expect(within(canvasElement).queryByText('order-sync:1.2')).not.toBeInTheDocument();
170
178
  },
171
179
  };
180
+
181
+ const QOG_REFERENCES: IInterfaceReference[] = [
182
+ { interface_kind: 'fsm', interface_name: 'order-flow', reference_id: 'q1' },
183
+ {
184
+ interface_kind: 'fsm',
185
+ interface_name: 'telegram-intake',
186
+ reference_id: 'q2',
187
+ logo: STORY_APP_LOGO,
188
+ },
189
+ { interface_kind: 'service', interface_name: 'notification-svc', reference_id: 'q3' },
190
+ ];
191
+
192
+ const chipImages = (canvasElement: HTMLElement) =>
193
+ Array.from(canvasElement.querySelectorAll('.reqore-tag img')).map((img) =>
194
+ img.getAttribute('src')
195
+ );
196
+
197
+ export const KindImage: Story = {
198
+ parameters: {
199
+ docs: {
200
+ description: {
201
+ story:
202
+ "Renders references with a host resolver that gives a Qog an image (the green mark) and a font icon fallback. The plain Qog shows the image; the app-triggered Qog keeps its stored trigger-app logo, because by default a reference's own logo comes first; the service has no image and shows its font icon.",
203
+ },
204
+ },
205
+ },
206
+ args: { references: QOG_REFERENCES, resolveInterfaceIcon: storyKindMark },
207
+ async play({ canvasElement }) {
208
+ await waitFor(() =>
209
+ expect(chipImages(canvasElement).sort()).toEqual([STORY_APP_LOGO, STORY_QOG_IMAGE].sort())
210
+ );
211
+ },
212
+ };
213
+
214
+ export const KindImagePreferred: Story = {
215
+ parameters: {
216
+ docs: {
217
+ description: {
218
+ story:
219
+ 'Renders the same references with preferKindImage: the kind image comes before a reference\'s own logo, so both Qogs show the green Qog mark and the service keeps its font icon.',
220
+ },
221
+ },
222
+ },
223
+ args: {
224
+ references: QOG_REFERENCES,
225
+ resolveInterfaceIcon: storyKindMark,
226
+ preferKindImage: true,
227
+ },
228
+ async play({ canvasElement }) {
229
+ await waitFor(() =>
230
+ expect(chipImages(canvasElement)).toEqual([STORY_QOG_IMAGE, STORY_QOG_IMAGE])
231
+ );
232
+ },
233
+ };
234
+
235
+ export const KindImageFailsToLoad: Story = {
236
+ parameters: {
237
+ docs: {
238
+ description: {
239
+ story:
240
+ 'Renders a Qog reference whose kind image cannot load: the chip keeps the font icon instead of drawing a broken picture.',
241
+ },
242
+ },
243
+ },
244
+ args: {
245
+ references: [QOG_REFERENCES[0]],
246
+ resolveInterfaceIcon: storyBrokenKindMark,
247
+ preferKindImage: true,
248
+ },
249
+ async play({ canvasElement }) {
250
+ const canvas = within(canvasElement);
251
+ await expect(canvas.getByText('order-flow')).toBeInTheDocument();
252
+ // Once the load has failed, no picture is drawn.
253
+ await whenImageSettled(STORY_BROKEN_IMAGE);
254
+ await expect(chipImages(canvasElement)).toEqual([]);
255
+ await expect(canvasElement.querySelector('.reqore-tag .reqore-icon')).toBeTruthy();
256
+ },
257
+ };
@@ -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}