@qoretechnologies/reqraft 0.10.65 → 0.10.66-pr.145.g124fc92

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 (161) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +130 -25
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  5. package/dist/components/form/engine/CompactToolbar.js +78 -15
  6. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  7. package/dist/components/form/engine/FormEngine.d.ts +48 -1
  8. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  9. package/dist/components/form/engine/FormEngine.js +260 -99
  10. package/dist/components/form/engine/FormEngine.js.map +1 -1
  11. package/dist/components/form/engine/OptionFieldMessages.d.ts +18 -2
  12. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  13. package/dist/components/form/engine/OptionFieldMessages.js +22 -3
  14. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  15. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  16. package/dist/components/form/engine/_structuredData/SchemaDataView.js +27 -1
  17. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  18. package/dist/components/form/engine/compactRowContext.d.ts +5 -0
  19. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  20. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  21. package/dist/components/form/engine/compactRowStyles.d.ts +12 -0
  22. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  23. package/dist/components/form/engine/compactRowStyles.js +72 -39
  24. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  25. package/dist/components/form/engine/compactToolbarContext.d.ts +3 -0
  26. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  27. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  28. package/dist/components/form/engine/formAppearance.d.ts +48 -0
  29. package/dist/components/form/engine/formAppearance.d.ts.map +1 -0
  30. package/dist/components/form/engine/formAppearance.js +50 -0
  31. package/dist/components/form/engine/formAppearance.js.map +1 -0
  32. package/dist/components/form/engine/readFirst.d.ts +14 -2
  33. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  34. package/dist/components/form/engine/readFirst.js +6 -2
  35. package/dist/components/form/engine/readFirst.js.map +1 -1
  36. package/dist/components/form/engine/rowFocus.d.ts +9 -1
  37. package/dist/components/form/engine/rowFocus.d.ts.map +1 -1
  38. package/dist/components/form/engine/rowFocus.js +17 -5
  39. package/dist/components/form/engine/rowFocus.js.map +1 -1
  40. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  41. package/dist/components/form/expressions/renderExpressionToText.js +21 -0
  42. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  43. package/dist/components/form/fields/ChoiceGroupHeading.d.ts +21 -0
  44. package/dist/components/form/fields/ChoiceGroupHeading.d.ts.map +1 -0
  45. package/dist/components/form/fields/ChoiceGroupHeading.js +33 -0
  46. package/dist/components/form/fields/ChoiceGroupHeading.js.map +1 -0
  47. package/dist/components/form/fields/Field.d.ts.map +1 -1
  48. package/dist/components/form/fields/Field.js +13 -11
  49. package/dist/components/form/fields/Field.js.map +1 -1
  50. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  51. package/dist/components/form/fields/allowed-values/AllowedValues.js +20 -7
  52. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  53. package/dist/components/form/fields/allowed-values/savedValues.d.ts +30 -0
  54. package/dist/components/form/fields/allowed-values/savedValues.d.ts.map +1 -0
  55. package/dist/components/form/fields/allowed-values/savedValues.js +18 -0
  56. package/dist/components/form/fields/allowed-values/savedValues.js.map +1 -0
  57. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  58. package/dist/components/form/fields/auto/AutoFormField.js +18 -8
  59. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  60. package/dist/components/form/fields/long-string/LongString.d.ts +7 -1
  61. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  62. package/dist/components/form/fields/long-string/LongString.js +9 -5
  63. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  64. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  65. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +6 -5
  66. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  67. package/dist/components/form/fields/number/Number.d.ts +4 -1
  68. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  69. package/dist/components/form/fields/number/Number.js +13 -5
  70. package/dist/components/form/fields/number/Number.js.map +1 -1
  71. package/dist/components/form/fields/radio-group/RadioGroup.d.ts.map +1 -1
  72. package/dist/components/form/fields/radio-group/RadioGroup.js +13 -7
  73. package/dist/components/form/fields/radio-group/RadioGroup.js.map +1 -1
  74. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  75. package/dist/components/form/fields/template/TemplateField.js +71 -42
  76. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  77. package/dist/components/form/index.d.ts +2 -0
  78. package/dist/components/form/index.d.ts.map +1 -1
  79. package/dist/components/form/index.js +2 -0
  80. package/dist/components/form/index.js.map +1 -1
  81. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -1
  82. package/dist/components/imageLightbox/ImageLightbox.js +2 -0
  83. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -1
  84. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +3 -2
  85. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  86. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  87. package/dist/components/supportTicket/ReferencePicker.d.ts +2 -1
  88. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  89. package/dist/components/supportTicket/ReferencePicker.js +19 -13
  90. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  91. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -1
  92. package/dist/components/supportTicket/TicketHeader.js +4 -1
  93. package/dist/components/supportTicket/TicketHeader.js.map +1 -1
  94. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -1
  95. package/dist/components/supportTicket/TicketReplyBox.js +29 -10
  96. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -1
  97. package/dist/components/supportTicket/useLoadedImages.d.ts +10 -0
  98. package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -1
  99. package/dist/components/supportTicket/useLoadedImages.js +63 -1
  100. package/dist/components/supportTicket/useLoadedImages.js.map +1 -1
  101. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  102. package/dist/components/ticketReferences/TicketReferences.js +6 -2
  103. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  104. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  105. package/dist/components/ticketThread/TicketThread.js +10 -16
  106. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  107. package/dist/helpers/allowedValues.d.ts +12 -0
  108. package/dist/helpers/allowedValues.d.ts.map +1 -1
  109. package/dist/helpers/allowedValues.js +20 -1
  110. package/dist/helpers/allowedValues.js.map +1 -1
  111. package/dist/helpers/fieldDescription.d.ts +16 -0
  112. package/dist/helpers/fieldDescription.d.ts.map +1 -0
  113. package/dist/helpers/fieldDescription.js +40 -0
  114. package/dist/helpers/fieldDescription.js.map +1 -0
  115. package/dist/helpers/singleLineString.d.ts.map +1 -1
  116. package/dist/helpers/singleLineString.js +1 -0
  117. package/dist/helpers/singleLineString.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/components/form/engine/CompactRow.tsx +143 -23
  120. package/src/components/form/engine/CompactToolbar.tsx +85 -16
  121. package/src/components/form/engine/FormEngine.stories.tsx +25 -13
  122. package/src/components/form/engine/FormEngine.tsx +367 -81
  123. package/src/components/form/engine/FormEngineAppearance.stories.tsx +540 -0
  124. package/src/components/form/engine/FormEngineStickyHeaders.stories.tsx +5 -1
  125. package/src/components/form/engine/OptionFieldMessages.tsx +73 -2
  126. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +17 -4
  127. package/src/components/form/engine/compactRowContext.ts +5 -0
  128. package/src/components/form/engine/compactRowStyles.ts +218 -38
  129. package/src/components/form/engine/compactToolbarContext.ts +3 -0
  130. package/src/components/form/engine/formAppearance.ts +78 -0
  131. package/src/components/form/engine/readFirst.ts +16 -3
  132. package/src/components/form/engine/rowFocus.ts +17 -6
  133. package/src/components/form/expressions/renderExpressionToText.ts +15 -0
  134. package/src/components/form/fields/ChoiceGroupHeading.tsx +38 -0
  135. package/src/components/form/fields/Field.tsx +11 -9
  136. package/src/components/form/fields/allowed-values/AllowedValues.tsx +21 -9
  137. package/src/components/form/fields/allowed-values/savedValues.ts +41 -0
  138. package/src/components/form/fields/auto/AutoFormField.tsx +17 -4
  139. package/src/components/form/fields/long-string/LongString.stories.tsx +27 -0
  140. package/src/components/form/fields/long-string/LongString.tsx +14 -3
  141. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +5 -4
  142. package/src/components/form/fields/number/Number.tsx +19 -4
  143. package/src/components/form/fields/radio-group/RadioGroup.tsx +13 -7
  144. package/src/components/form/fields/template/TemplateField.tsx +32 -4
  145. package/src/components/form/index.tsx +2 -0
  146. package/src/components/imageLightbox/ImageLightbox.tsx +2 -0
  147. package/src/components/supportTicket/InterfaceReferenceTags.tsx +3 -2
  148. package/src/components/supportTicket/ReferencePicker.stories.tsx +0 -4
  149. package/src/components/supportTicket/ReferencePicker.tsx +22 -12
  150. package/src/components/supportTicket/TicketConversation.stories.tsx +0 -1
  151. package/src/components/supportTicket/TicketHeader.tsx +2 -1
  152. package/src/components/supportTicket/TicketReplyBox.stories.tsx +24 -21
  153. package/src/components/supportTicket/TicketReplyBox.tsx +27 -5
  154. package/src/components/supportTicket/useLoadedImages.ts +20 -0
  155. package/src/components/ticketReferences/TicketReferences.stories.tsx +26 -0
  156. package/src/components/ticketReferences/TicketReferences.tsx +12 -4
  157. package/src/components/ticketThread/TicketThread.stories.tsx +71 -2
  158. package/src/components/ticketThread/TicketThread.tsx +15 -28
  159. package/src/helpers/allowedValues.ts +19 -0
  160. package/src/helpers/fieldDescription.ts +39 -0
  161. package/src/helpers/singleLineString.ts +1 -0
@@ -0,0 +1,540 @@
1
+ import { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useState } from 'react';
3
+ import { expect, fn, waitFor } from 'storybook/test';
4
+ import {
5
+ _testsClickText,
6
+ _testsWaitForText,
7
+ _testsWaitForTextToNotExist,
8
+ _testsWithViewport,
9
+ } from '../../../stories/Tests/utils';
10
+ import {
11
+ FormEngine,
12
+ IFormEngineGroup,
13
+ IFormEngineProps,
14
+ IOptions,
15
+ IOptionsSchemaArg,
16
+ } from './FormEngine';
17
+ import { CALM_REQUIRED_LABEL, CALM_REQUIRED_READ_LABEL } from './formAppearance';
18
+
19
+ /*
20
+ * The calm appearance against the default one, over the fields a workflow
21
+ * creator actually serves (display names, groups and descriptions as the Qorus
22
+ * server's workflow metadata gives them), so the comparison is about the look
23
+ * and nothing else.
24
+ */
25
+
26
+ const groups: Record<string, IFormEngineGroup> = {
27
+ info: { label: 'Info', sort: 1 },
28
+ resources: { label: 'Resources', sort: 2 },
29
+ scaling: { label: 'Scaling', sort: 3 },
30
+ business: { label: 'Business', sort: 4 },
31
+ };
32
+
33
+ // `group` is server-supplied metadata not yet in the typed schema; widen locally.
34
+ type TAppearanceField = IOptionsSchemaArg & { group?: string };
35
+
36
+ const options: Record<string, TAppearanceField> = {
37
+ name: {
38
+ type: 'string',
39
+ display_name: 'Internal Name',
40
+ short_desc: 'The internal technical name of the object',
41
+ group: 'info',
42
+ preselected: true,
43
+ },
44
+ display_name: {
45
+ type: 'string',
46
+ display_name: 'Name',
47
+ short_desc: 'The display name of the object',
48
+ required: true,
49
+ group: 'info',
50
+ },
51
+ desc: {
52
+ type: 'long-string',
53
+ display_name: 'Description',
54
+ short_desc: 'The markdown description of the object',
55
+ required: true,
56
+ group: 'info',
57
+ },
58
+ version: {
59
+ type: 'string',
60
+ display_name: 'Version',
61
+ short_desc: 'The object’s version',
62
+ required: true,
63
+ group: 'info',
64
+ },
65
+ lang: {
66
+ type: 'string',
67
+ display_name: 'Language',
68
+ required: true,
69
+ group: 'info',
70
+ messages: [
71
+ {
72
+ intent: 'info',
73
+ content: 'Changing the language later generates a new source skeleton.',
74
+ },
75
+ ],
76
+ allowed_values: [
77
+ { value: { type: 'string', value: 'qore' }, display_name: 'Qore' },
78
+ { value: { type: 'string', value: 'python' }, display_name: 'Python' },
79
+ { value: { type: 'string', value: 'java' }, display_name: 'Java' },
80
+ ],
81
+ },
82
+ classes: {
83
+ type: 'string',
84
+ display_name: 'Classes',
85
+ short_desc: 'Any classes used by the object',
86
+ group: 'resources',
87
+ preselected: true,
88
+ },
89
+ autostart: {
90
+ type: 'int',
91
+ display_name: 'Autostart',
92
+ short_desc: 'The number of execution instances started automatically for the workflow',
93
+ group: 'scaling',
94
+ preselected: true,
95
+ },
96
+ max_instances: {
97
+ type: 'int',
98
+ display_name: 'Maximum Instances',
99
+ short_desc: 'The maximum number of workflow execution instances that can be started',
100
+ group: 'scaling',
101
+ preselected: true,
102
+ },
103
+ remote: {
104
+ type: 'bool',
105
+ display_name: 'Remote?',
106
+ short_desc: 'Should this interface run in a dedicated process?',
107
+ group: 'scaling',
108
+ preselected: true,
109
+ },
110
+ business_category: {
111
+ type: 'string',
112
+ display_name: 'Business Category',
113
+ short_desc: 'Automation intelligence category for this interface',
114
+ group: 'business',
115
+ preselected: true,
116
+ },
117
+ author: {
118
+ type: 'string',
119
+ display_name: 'Author',
120
+ short_desc: 'The author of the object',
121
+ group: 'info',
122
+ },
123
+ sla_threshold: {
124
+ type: 'int',
125
+ display_name: 'SLA Threshold',
126
+ short_desc: 'The time in seconds before which workflow orders should get a final status',
127
+ group: 'scaling',
128
+ },
129
+ };
130
+
131
+ const filledValue: IOptions = {
132
+ name: { type: 'string', value: 'supplier-payment-release' },
133
+ display_name: { type: 'string', value: 'Supplier Payment Release' },
134
+ desc: {
135
+ type: 'long-string',
136
+ value: 'Reconciles inbound orders with supplier confirmations and payment holds before release.',
137
+ },
138
+ version: { type: 'string', value: '1.0' },
139
+ lang: { type: 'string', value: 'qore' },
140
+ classes: { type: 'string', value: 'SupplyChainReconciler' },
141
+ autostart: { type: 'int', value: 1 },
142
+ max_instances: { type: 'int', value: 20 },
143
+ remote: { type: 'bool', value: false },
144
+ business_category: { type: 'string', value: 'Supply Chain' },
145
+ };
146
+
147
+ const newFormValue: IOptions = {
148
+ lang: { type: 'string', value: 'qore' },
149
+ };
150
+
151
+ const meta: Meta<typeof FormEngine> = {
152
+ component: FormEngine,
153
+ title: 'Form/Engine/FormEngine Appearance',
154
+ args: {
155
+ name: 'workflow',
156
+ compact: true,
157
+ options,
158
+ groups,
159
+ value: filledValue,
160
+ onChange: fn(),
161
+ },
162
+ parameters: {
163
+ layout: 'fullscreen',
164
+ },
165
+ render: ({ value, onChange, ...rest }: IFormEngineProps) => {
166
+ const [val, setValue] = useState(value);
167
+
168
+ return (
169
+ <div
170
+ style={{
171
+ padding: '24px 16px',
172
+ width: '100%',
173
+ maxWidth: 1040,
174
+ margin: '0 auto',
175
+ boxSizing: 'border-box',
176
+ }}
177
+ >
178
+ <FormEngine
179
+ {...rest}
180
+ value={val}
181
+ onChange={(name, next, info) => {
182
+ setValue(next);
183
+ onChange?.(name, next, info);
184
+ }}
185
+ />
186
+ </div>
187
+ );
188
+ },
189
+ };
190
+
191
+ export default meta;
192
+ type Story = StoryObj<typeof meta>;
193
+
194
+ /* --------------------------------------------------------------------------
195
+ * Measurements the calm look depends on
196
+ * ------------------------------------------------------------------------ */
197
+
198
+ const rgbOf = (color: string): number[] =>
199
+ (color.match(/[\d.]+/g) || []).slice(0, 3).map((channel) => Number(channel));
200
+
201
+ /** Amber: red well above blue (the warning colour), as opposed to a quiet grey. */
202
+ const isAmber = (color: string): boolean => {
203
+ const [r, , b] = rgbOf(color);
204
+ return r - b > 60;
205
+ };
206
+
207
+ /** A grey: no channel more than a few steps from the others. */
208
+ const isGrey = (color: string): boolean => {
209
+ const [r, g, b] = rgbOf(color);
210
+ return Math.max(r, g, b) - Math.min(r, g, b) <= 8;
211
+ };
212
+
213
+ const box = (key: 'attention' | 'set' | 'optional'): HTMLElement | undefined =>
214
+ [...document.querySelectorAll<HTMLElement>('.options-readfirst-group')].find((element) => {
215
+ const heading = element.querySelector('.reqore-panel-title')?.textContent ?? '';
216
+ // The first box is "To fill in" until something in it is a fault (a new form).
217
+ return key === 'attention' ? /^(Needs attention|To fill in)/.test(heading)
218
+ : heading.startsWith(key === 'set' ? 'Set' : 'Optional');
219
+ });
220
+
221
+ const row = (field: string): HTMLElement | null =>
222
+ document.querySelector<HTMLElement>(`.readfirst-row[data-field="${field}"]`);
223
+
224
+ /** The section is a plain panel: a grey surface, a grey edge, and no tint. */
225
+ const expectCalmSection = (element: HTMLElement | undefined) => {
226
+ expect(element).toBeTruthy();
227
+ const style = getComputedStyle(element!);
228
+ expect(isGrey(style.backgroundColor)).toBe(true);
229
+ expect(style.backgroundImage).toBe('none');
230
+ expect(isGrey(style.borderTopColor)).toBe(true);
231
+ expect(Number.parseFloat(style.borderTopWidth)).toBe(1);
232
+ // The count is a quiet number, not a tag.
233
+ expect(element!.querySelector('.reqore-panel-title .reqore-tag')).toBeNull();
234
+ expect(element!.querySelector('.options-readfirst-group-count')).toBeTruthy();
235
+ };
236
+
237
+ const expectNoUppercase = (selector: string) => {
238
+ const elements = [...document.querySelectorAll<HTMLElement>(selector)];
239
+ expect(elements.length).toBeGreaterThan(0);
240
+ for (const element of elements) {
241
+ expect(getComputedStyle(element).textTransform).not.toBe('uppercase');
242
+ }
243
+ };
244
+
245
+ /** No status dot on a row that is filled and fine. */
246
+ const expectNoDot = (field: string) => {
247
+ expect(row(field)).toBeTruthy();
248
+ expect(row(field)!.querySelector('.options-readfirst-status-dot')).toBeNull();
249
+ };
250
+
251
+ /* --------------------------------------------------------------------------
252
+ * Stories
253
+ * ------------------------------------------------------------------------ */
254
+
255
+ export const DefaultLook: Story = {
256
+ parameters: {
257
+ docs: {
258
+ description: {
259
+ story:
260
+ 'The default appearance over a filled workflow form, for comparison with **Calm**: tinted Set box, a green Ready and meter, a green dot on every filled row, upper-case group labels and a red/green Yes/No chip.',
261
+ },
262
+ },
263
+ },
264
+ play: async () => {
265
+ await _testsWaitForText('Supplier Payment Release');
266
+ // The default look is untouched: the tinted box and its count tag.
267
+ await waitFor(() => expect(box('set')).toBeTruthy());
268
+ expect(box('set')!.querySelector('.reqore-panel-title .reqore-tag')).toBeTruthy();
269
+ expect(row('display_name')!.querySelector('.options-readfirst-status-dot')).toBeTruthy();
270
+ },
271
+ };
272
+
273
+ export const Calm: Story = {
274
+ args: {
275
+ appearance: 'calm',
276
+ },
277
+ parameters: {
278
+ docs: {
279
+ description: {
280
+ story:
281
+ '`appearance="calm"` over the same filled form: the sections are plain panels on the theme’s panel surface with label/value rows split by hairlines, labels grey, group headings in sentence case, the count a quiet number, no dot on a healthy row, No as a word, and a short meter whose done part is grey.',
282
+ },
283
+ },
284
+ },
285
+ play: async () => {
286
+ await _testsWaitForText('Supplier Payment Release');
287
+ await waitFor(() => expect(box('set')).toBeTruthy());
288
+
289
+ // Sections are calm panels — no green tint, no recessed Optional box.
290
+ expectCalmSection(box('set'));
291
+ expectCalmSection(box('optional'));
292
+ expect(getComputedStyle(box('optional')!).backgroundColor).toBe(
293
+ getComputedStyle(box('set')!).backgroundColor
294
+ );
295
+
296
+ // Rows are split by one full-width hairline each, and there is no gap
297
+ // between them.
298
+ const rows = [...box('set')!.querySelectorAll<HTMLElement>('.readfirst-row')];
299
+ expect(rows.length).toBeGreaterThan(4);
300
+ const second = rows[2];
301
+ expect(Number.parseFloat(getComputedStyle(second).borderTopWidth)).toBe(1);
302
+
303
+ // Sentence case everywhere, grey labels.
304
+ expectNoUppercase('.options-readfirst-group-label');
305
+ expect(document.querySelector('.options-readfirst-group-label')?.textContent).toBe('Info');
306
+ const label = row('display_name')!.querySelector<HTMLElement>('.options-readfirst-label-text')!;
307
+ const value = row('display_name')!.querySelector<HTMLElement>('.options-readfirst-valuetext')!;
308
+ expect(getComputedStyle(label).color).not.toBe(getComputedStyle(value).color);
309
+
310
+ // The section's header label, its group headings and its rows' labels all
311
+ // start on one left edge.
312
+ const headerText = box('set')!
313
+ .querySelector<HTMLElement>('.reqore-panel-title p')!
314
+ .getBoundingClientRect().left;
315
+ expect(Math.abs(headerText - label.getBoundingClientRect().left)).toBeLessThanOrEqual(1);
316
+ const heading = document.querySelector<HTMLElement>('.options-readfirst-group-label')!;
317
+ expect(
318
+ Math.abs(
319
+ heading.getBoundingClientRect().left +
320
+ Number.parseFloat(getComputedStyle(heading).paddingLeft) -
321
+ label.getBoundingClientRect().left
322
+ )
323
+ ).toBeLessThanOrEqual(1);
324
+
325
+ // No dot on a filled row, no asterisk on a required one.
326
+ expectNoDot('display_name');
327
+ expectNoDot('version');
328
+ expect(row('display_name')!.querySelectorAll('svg').length).toBe(0);
329
+
330
+ // A note that only informs is not blue.
331
+ const note = row('lang')!.querySelector<HTMLElement>('.options-readfirst-info-panel .reqore-message')!;
332
+ expect(note).toBeTruthy();
333
+ expect(isGrey(getComputedStyle(note).backgroundColor)).toBe(true);
334
+ expect(isGrey(getComputedStyle(note).borderTopColor)).toBe(true);
335
+
336
+ // Yes/No is a word, not a coloured chip.
337
+ expect(row('remote')!.textContent).toContain('No');
338
+ expect(row('remote')!.querySelector('.reqore-tag')).toBeNull();
339
+
340
+ // The readiness line: the word in the text colour, the meter's done part grey.
341
+ const status = document.querySelector<HTMLElement>('.options-readfirst-status')!;
342
+ expect(status.textContent).toBe('Ready');
343
+ expect(isGrey(getComputedStyle(status).color)).toBe(true);
344
+ const bar = document.querySelector<HTMLElement>('.options-readfirst-completion-bar')!;
345
+ expect(isGrey(getComputedStyle(bar, '::before').backgroundColor)).toBe(true);
346
+ expect(bar.getBoundingClientRect().width).toBeLessThanOrEqual(361);
347
+ expect(bar.getBoundingClientRect().height).toBe(4);
348
+ },
349
+ };
350
+
351
+ export const CalmNeedsAttention: Story = {
352
+ args: {
353
+ appearance: 'calm',
354
+ value: newFormValue,
355
+ // After a submit attempt: every empty required field is now a fault. A form nobody
356
+ // has been to yet reads "To fill in" instead (CalmNewForm).
357
+ revealRequired: true,
358
+ },
359
+ parameters: {
360
+ docs: {
361
+ description: {
362
+ story:
363
+ 'A form after a submit attempt (`revealRequired`) in the calm look: the Needs attention section carries one amber dot and a word, each empty required field says **Required · add a value** in amber where its value would be (no red asterisk, no red message), and only the outstanding part of the meter is coloured. Opening a required field shows the same words under the editor as plain amber text.',
364
+ },
365
+ },
366
+ },
367
+ play: async () => {
368
+ await waitFor(() => expect(box('attention')).toBeTruthy(), { timeout: 10000 });
369
+ expectCalmSection(box('attention'));
370
+ expect(box('attention')!.querySelector('.options-readfirst-group-dot')).toBeTruthy();
371
+
372
+ // The missing values are amber words in the value column.
373
+ await waitFor(() =>
374
+ expect(
375
+ [...document.querySelectorAll('.options-readfirst-valuetext')].filter(
376
+ (element) => element.textContent === CALM_REQUIRED_LABEL
377
+ ).length
378
+ ).toBe(3)
379
+ );
380
+ await _testsWaitForTextToNotExist('This field is required');
381
+ const word = row('display_name')!.querySelector<HTMLElement>('.options-readfirst-valuetext')!;
382
+ expect(isGrey(getComputedStyle(word).color)).toBe(false);
383
+ expect(getComputedStyle(word).fontStyle).toBe('normal');
384
+ // The amber word is the row's one signal: no dot beside it.
385
+ expectNoDot('display_name');
386
+
387
+ // Only the outstanding part of the readiness line is coloured.
388
+ const status = document.querySelector<HTMLElement>('.options-readfirst-status')!;
389
+ expect(status.textContent).toBe('Incomplete');
390
+ expect(isGrey(getComputedStyle(status).color)).toBe(true);
391
+ const link = document.querySelector<HTMLElement>('.options-readfirst-attention-link')!;
392
+ expect(isGrey(getComputedStyle(link).color)).toBe(false);
393
+
394
+ // Inside an open required field the requirement is plain amber text.
395
+ await _testsClickText('Name');
396
+ await waitFor(() =>
397
+ expect(document.querySelector('.options-field-message')?.textContent).toBe(
398
+ CALM_REQUIRED_LABEL
399
+ )
400
+ );
401
+ expect(document.querySelector('.options-field-message')?.closest('.reqore-tag')).toBeNull();
402
+ },
403
+ };
404
+
405
+ export const CalmChoiceHeading: Story = {
406
+ args: {
407
+ appearance: 'calm',
408
+ },
409
+ parameters: {
410
+ docs: {
411
+ description: {
412
+ story:
413
+ 'A choice field opened in the calm look: its three choices are headed by the field’s name as written (“Language:”), not an upper-case micro label.',
414
+ },
415
+ },
416
+ },
417
+ play: async () => {
418
+ await _testsWaitForText('Supplier Payment Release');
419
+ await _testsClickText('Qore');
420
+ await waitFor(() =>
421
+ expect(document.querySelector('.reqraft-choice-group-heading')).toBeTruthy()
422
+ );
423
+ expectNoUppercase('.reqraft-choice-group-heading');
424
+ },
425
+ };
426
+
427
+ export const CalmReadOnly: Story = {
428
+ args: {
429
+ appearance: 'calm',
430
+ readOnly: true,
431
+ value: { ...filledValue, version: { type: 'string', value: '' } },
432
+ },
433
+ parameters: {
434
+ docs: {
435
+ description: {
436
+ story:
437
+ 'The calm look in read mode: one list of label/value rows split by hairlines, flush with the host’s edge and with no card of its own (a read view usually sits in one already); an unset required value reads **Required · not set**, because a reader cannot add it.',
438
+ },
439
+ },
440
+ },
441
+ play: async () => {
442
+ await _testsWaitForText('Supplier Payment Release');
443
+ expect(document.querySelector('.options-readfirst-group')).toBeNull();
444
+ const first = document.querySelector<HTMLElement>('.readfirst-read-list > *')!;
445
+ expect(Number.parseFloat(getComputedStyle(first).borderTopWidth)).toBe(0);
446
+ expect(Number.parseFloat(getComputedStyle(row('display_name')!).borderTopWidth)).toBe(1);
447
+ expect(document.querySelectorAll('.options-readfirst-status-dot').length).toBe(0);
448
+ expect(row('version')!.textContent).toContain(CALM_REQUIRED_READ_LABEL);
449
+ expect(row('remote')!.querySelector('.reqore-tag')).toBeNull();
450
+ },
451
+ };
452
+
453
+ export const CalmNewForm: Story = {
454
+ args: {
455
+ appearance: 'calm',
456
+ value: newFormValue,
457
+ },
458
+ parameters: {
459
+ docs: {
460
+ description: {
461
+ story:
462
+ 'A brand-new form in the calm look. A new form is not already wrong: its required fields are still to fill in, so the first section reads **To fill in** with no amber dot, each empty required field says **Required · add a value** in the quiet colour (under its editor too, while it is open for typing), and the readiness line says "3 to fill in". Opening a required field and leaving it empty makes it a fault: the section becomes **Needs attention**, with its amber dot, that field\'s words turn amber, and the readiness line says "1 need attention · 2 to fill in".',
463
+ },
464
+ },
465
+ },
466
+ play: async () => {
467
+ await waitFor(() => expect(box('attention')).toBeTruthy(), { timeout: 10000 });
468
+ const heading = () => box('attention')!.querySelector('.reqore-panel-title')!.textContent ?? '';
469
+ expect(heading()).toContain('To fill in');
470
+ expect(box('attention')!.querySelector('.options-readfirst-group-dot')).toBeNull();
471
+ const word = () =>
472
+ row('display_name')!.querySelector<HTMLElement>('.options-readfirst-valuetext')!;
473
+ await waitFor(() => expect(word().textContent).toBe(CALM_REQUIRED_LABEL));
474
+ expect(isAmber(getComputedStyle(word()).color)).toBe(false);
475
+ const link = document.querySelector<HTMLElement>('.options-readfirst-attention-link')!;
476
+ expect(link.textContent).toContain('to fill in');
477
+ expect(isAmber(getComputedStyle(link).color)).toBe(false);
478
+
479
+ // Open Name: an open field is not yet a left one, so the line under its editor
480
+ // says what it needs in the quiet colour (a host that opens the first empty field
481
+ // on a new form must not show it as already wrong).
482
+ await _testsClickText('Name');
483
+ const openMessage = await waitFor(() => {
484
+ // An open row is no longer a read-first row, so it is found by its field alone.
485
+ const found = Array.from(
486
+ document.querySelectorAll<HTMLElement>('[data-field="display_name"] .options-field-message')
487
+ ).find((el) => el.textContent === CALM_REQUIRED_LABEL);
488
+ expect(found).toBeTruthy();
489
+ return found!;
490
+ });
491
+ expect(isAmber(getComputedStyle(openMessage).color)).toBe(false);
492
+ expect(heading()).toContain('To fill in');
493
+
494
+ // Then open another field: Name has been visited and left empty.
495
+ await _testsClickText('Version');
496
+ await waitFor(() => expect(heading()).toContain('Needs attention'));
497
+ // Only the visited field is a fault; the other two are still to fill in.
498
+ const line = document.querySelector<HTMLElement>('.options-readfirst-completion')!.textContent;
499
+ expect(line).toContain('1 need attention');
500
+ expect(line).toContain('2 to fill in');
501
+ expect(box('attention')!.querySelector('.options-readfirst-group-dot')).toBeTruthy();
502
+ await waitFor(() => expect(isAmber(getComputedStyle(word()).color)).toBe(true));
503
+ },
504
+ };
505
+
506
+ export const CalmOnAPhone: Story = {
507
+ args: {
508
+ appearance: 'calm',
509
+ value: newFormValue,
510
+ },
511
+ parameters: {
512
+ qlip: { viewport: { width: 390, height: 844 } },
513
+ docs: {
514
+ description: {
515
+ story:
516
+ 'The calm look on a 390px phone: the readiness meter wraps under its words, each row stacks its value under its name inside the section, and nothing scrolls sideways.',
517
+ },
518
+ },
519
+ },
520
+ play: async ({ canvasElement }) => {
521
+ await _testsWithViewport(390, 844, async (narrowed) => {
522
+ await waitFor(() => expect(box('attention')).toBeTruthy(), { timeout: 10000 });
523
+ if (!narrowed) return;
524
+ await waitFor(() =>
525
+ expect(document.querySelector('.readfirst-narrow')).toBeTruthy()
526
+ );
527
+ expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(
528
+ document.documentElement.clientWidth
529
+ );
530
+ const section = box('attention')!.getBoundingClientRect();
531
+ for (const element of canvasElement.querySelectorAll<HTMLElement>(
532
+ '.options-readfirst-group .readfirst-row'
533
+ )) {
534
+ const rect = element.getBoundingClientRect();
535
+ expect(rect.left).toBeGreaterThanOrEqual(section.left - 1);
536
+ expect(rect.right).toBeLessThanOrEqual(section.right + 1);
537
+ }
538
+ });
539
+ },
540
+ };
@@ -122,8 +122,12 @@ const scroller = () => document.getElementById(SCROLLER) as HTMLElement;
122
122
 
123
123
  /** One box's header element, found by the label it carries. */
124
124
  const boxHeader = (label: string): HTMLElement => {
125
+ // The first box is "To fill in" until something in it is a fault (a new form).
126
+ const labels = label === 'Needs attention' ? ['Needs attention', 'To fill in'] : [label];
125
127
  const box = Array.from(document.querySelectorAll('.options-readfirst-group')).find((el) =>
126
- (el.querySelector(':scope > .reqore-panel-title')?.textContent ?? '').includes(label)
128
+ labels.some((name) =>
129
+ (el.querySelector(':scope > .reqore-panel-title')?.textContent ?? '').includes(name)
130
+ )
127
131
  );
128
132
  if (!box) {
129
133
  throw new Error(`no status box labelled ${label}`);
@@ -1,6 +1,13 @@
1
- import { ReqoreControlGroup, ReqoreTag, ReqoreVerticalSpacer } from '@qoretechnologies/reqore';
1
+ import {
2
+ ReqoreControlGroup,
3
+ ReqoreP,
4
+ ReqoreTag,
5
+ ReqoreVerticalSpacer,
6
+ useReqoreTheme,
7
+ } from '@qoretechnologies/reqore';
2
8
  import { IReqoreTagProps } from '@qoretechnologies/reqore/dist/components/Tag';
3
9
  import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
10
+ import { getReadableColor } from '@qoretechnologies/reqore/dist/helpers/colors';
4
11
  import { IQorusFormField, IQorusFormSchema, TQorusForm } from '@qoretechnologies/ts-toolkit';
5
12
  import { isArray, size } from 'lodash';
6
13
  import { useMemo } from 'react';
@@ -19,6 +26,7 @@ import {
19
26
  unsatisfiedRequiredGroups,
20
27
  } from '../../../helpers/validations';
21
28
  import { OptionsContext } from './optionsContext';
29
+ import { CALM_REQUIRED_LABEL, CALM_REQUIRED_READ_LABEL, useIsCalmForm } from './formAppearance';
22
30
 
23
31
  /** A `messages` entry that appears only for certain values of its siblings. */
24
32
  export interface IConditionalFieldMessage {
@@ -335,6 +343,22 @@ export interface IOptionFieldMessagesProps {
335
343
  * reqraft's built-in editors are known.
336
344
  */
337
345
  isRendererOnly?: (type?: any) => boolean;
346
+ /**
347
+ * The calm appearance says an empty required field the calm way: an amber
348
+ * "Required · add a value" (or, read-only, "Required · not set") rather than
349
+ * a red "This field is required". Waiting for a person is amber; red is for
350
+ * something that failed.
351
+ */
352
+ calm?: boolean;
353
+ /** Read-only: the reader cannot add a value, so the calm wording only states it. */
354
+ readOnly?: boolean;
355
+ /**
356
+ * Nobody has been to this field yet. The requirement is still stated, but with no
357
+ * intent: a new form is not already wrong, and saying what is required is not saying
358
+ * something failed. It takes its colour once the person has opened the field and left
359
+ * it empty (`FormEngine`'s `visitedOptions` / `revealRequired`).
360
+ */
361
+ requirementPending?: boolean;
338
362
  }
339
363
 
340
364
  /**
@@ -349,6 +373,9 @@ export const getOptionFieldMessages = ({
349
373
  allOptions,
350
374
  getType,
351
375
  isRendererOnly,
376
+ calm,
377
+ readOnly,
378
+ requirementPending,
352
379
  }: IOptionFieldMessagesProps): IReqoreTagProps[] => {
353
380
  const optionSchema = schema[name];
354
381
  const result: IReqoreTagProps[] = [];
@@ -394,7 +421,13 @@ export const getOptionFieldMessages = ({
394
421
  // locked field is not asked for: the dependency message below says what
395
422
  // unlocks it, and until then there is nothing to fill in.
396
423
  if (optionSchema?.required && !locked) {
397
- result.push({ label: 'This field is required', intent: 'danger' });
424
+ const message =
425
+ calm ?
426
+ { label: readOnly ? CALM_REQUIRED_READ_LABEL : CALM_REQUIRED_LABEL, intent: 'warning' }
427
+ : { label: 'This field is required', intent: 'danger' };
428
+ result.push(
429
+ requirementPending ? { label: message.label } : (message as IReqoreTagProps)
430
+ );
398
431
  }
399
432
 
400
433
  if (
@@ -440,7 +473,10 @@ export const OptionFieldMessages = ({
440
473
  allOptions,
441
474
  getType,
442
475
  isRendererOnly,
476
+ requirementPending,
443
477
  }: IOptionFieldMessagesProps) => {
478
+ const calm = useIsCalmForm();
479
+ const theme = useReqoreTheme();
444
480
  const messages: IReqoreTagProps[] = useMemo(
445
481
  () =>
446
482
  getOptionFieldMessages({
@@ -450,6 +486,8 @@ export const OptionFieldMessages = ({
450
486
  allOptions,
451
487
  getType,
452
488
  isRendererOnly,
489
+ calm,
490
+ requirementPending,
453
491
  }),
454
492
  [
455
493
  JSON.stringify(schema),
@@ -457,6 +495,8 @@ export const OptionFieldMessages = ({
457
495
  JSON.stringify(allOptions),
458
496
  name,
459
497
  isRendererOnly,
498
+ calm,
499
+ requirementPending,
460
500
  ]
461
501
  );
462
502
 
@@ -464,6 +504,37 @@ export const OptionFieldMessages = ({
464
504
  return null;
465
505
  }
466
506
 
507
+ // Calm: each message is a line of text in its own colour under the editor —
508
+ // a word, not a tinted chip with a warning glyph.
509
+ if (calm) {
510
+ return (
511
+ <ReqoreControlGroup
512
+ className='options-field-messages'
513
+ vertical
514
+ gapSize='tiny'
515
+ style={{ marginTop: 6 }}
516
+ >
517
+ {messages.map((message, index) => (
518
+ <ReqoreP
519
+ key={index}
520
+ size='small'
521
+ intent={message.intent}
522
+ // A requirement nobody has been asked about yet carries no intent: it is
523
+ // said in the same faint colour as the row's pending word, not in full text.
524
+ effect={
525
+ message.intent ? undefined : (
526
+ { color: `${getReadableColor(theme, undefined, undefined, true)}a6` }
527
+ )
528
+ }
529
+ className='options-field-message'
530
+ >
531
+ {message.label}
532
+ </ReqoreP>
533
+ ))}
534
+ </ReqoreControlGroup>
535
+ );
536
+ }
537
+
467
538
  return (
468
539
  <>
469
540
  <ReqoreVerticalSpacer height={5} />