@qoretechnologies/reqraft 0.10.35-pr.92.ga86e098 → 0.10.35

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 (57) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +24 -7
  3. package/dist/components/form/engine/CompactRow.js.map +1 -1
  4. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  5. package/dist/components/form/engine/FormEngine.js +11 -1
  6. package/dist/components/form/engine/FormEngine.js.map +1 -1
  7. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  8. package/dist/components/form/engine/readFirst.js +8 -2
  9. package/dist/components/form/engine/readFirst.js.map +1 -1
  10. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  11. package/dist/components/form/expressions/ExpressionField.js +2 -2
  12. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  13. package/dist/components/form/fields/Field.d.ts.map +1 -1
  14. package/dist/components/form/fields/Field.js +7 -0
  15. package/dist/components/form/fields/Field.js.map +1 -1
  16. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  17. package/dist/components/form/fields/auto/AutoFormField.js +10 -0
  18. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  19. package/dist/components/form/fields/select/Select.d.ts +6 -1
  20. package/dist/components/form/fields/select/Select.d.ts.map +1 -1
  21. package/dist/components/form/fields/select/Select.js +5 -5
  22. package/dist/components/form/fields/select/Select.js.map +1 -1
  23. package/dist/components/form/fields/timeout/Timeout.d.ts +24 -0
  24. package/dist/components/form/fields/timeout/Timeout.d.ts.map +1 -0
  25. package/dist/components/form/fields/timeout/Timeout.js +73 -0
  26. package/dist/components/form/fields/timeout/Timeout.js.map +1 -0
  27. package/dist/components/form/index.d.ts +1 -0
  28. package/dist/components/form/index.d.ts.map +1 -1
  29. package/dist/components/form/index.js +1 -0
  30. package/dist/components/form/index.js.map +1 -1
  31. package/dist/helpers/common.d.ts +17 -0
  32. package/dist/helpers/common.d.ts.map +1 -1
  33. package/dist/helpers/common.js +42 -1
  34. package/dist/helpers/common.js.map +1 -1
  35. package/dist/helpers/validations.d.ts.map +1 -1
  36. package/dist/helpers/validations.js +50 -20
  37. package/dist/helpers/validations.js.map +1 -1
  38. package/dist/types/Form.d.ts +2 -2
  39. package/dist/types/Form.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/components/form/engine/CompactRow.tsx +43 -20
  42. package/src/components/form/engine/FormEngine.stories.tsx +17 -11
  43. package/src/components/form/engine/FormEngine.tsx +10 -1
  44. package/src/components/form/engine/readFirst.ts +10 -3
  45. package/src/components/form/expressions/ExpressionField.stories.tsx +23 -16
  46. package/src/components/form/expressions/ExpressionField.tsx +23 -18
  47. package/src/components/form/fields/Field.tsx +17 -0
  48. package/src/components/form/fields/auto/AutoFormField.stories.tsx +30 -0
  49. package/src/components/form/fields/auto/AutoFormField.tsx +19 -0
  50. package/src/components/form/fields/select/Select.stories.tsx +26 -0
  51. package/src/components/form/fields/select/Select.tsx +9 -3
  52. package/src/components/form/fields/timeout/Timeout.stories.tsx +228 -0
  53. package/src/components/form/fields/timeout/Timeout.tsx +87 -0
  54. package/src/components/form/index.tsx +1 -0
  55. package/src/helpers/common.ts +36 -0
  56. package/src/helpers/validations.ts +49 -13
  57. package/src/types/Form.ts +4 -1
@@ -55,6 +55,19 @@ const meta = {
55
55
  export default meta;
56
56
  type Story = StoryObj<typeof meta>;
57
57
 
58
+ /** The DPQL editor overlays "Connecting to language server…" / "Loading
59
+ * schema…" while its LSP session starts — a snapshot must never catch that
60
+ * transient, so every Text-mode play ends by waiting the overlay out. */
61
+ const waitForLspIdle = (canvasElement: HTMLElement) =>
62
+ waitFor(
63
+ () => {
64
+ expect(canvasElement.querySelector('[contenteditable="true"]')).toBeInTheDocument();
65
+ expect(canvasElement.textContent).not.toContain('Connecting to language server');
66
+ expect(canvasElement.textContent).not.toContain('Loading schema');
67
+ },
68
+ { timeout: 10000 }
69
+ );
70
+
58
71
  /** The shell with a populated expression — the Visual/Text mode toggle. */
59
72
  export const Default: Story = {
60
73
  args: {
@@ -82,9 +95,8 @@ export const Default: Story = {
82
95
  };
83
96
 
84
97
  /**
85
- * Empty expression — the Text-mode "Parsed" preview shows `(empty)`. (The
86
- * preview element lives in Text mode; an empty AST renders to '' which the
87
- * shell displays as `(empty)`.)
98
+ * Empty expression — an empty AST renders no "Parsed" box at all (nothing to
99
+ * parse), just the bare editor waiting for input.
88
100
  */
89
101
  export const Empty: Story = {
90
102
  args: {
@@ -95,7 +107,7 @@ export const Empty: Story = {
95
107
  docs: {
96
108
  description: {
97
109
  story:
98
- 'Renders ExpressionField in Text mode with an empty expression AST — the "Parsed" preview shows the "(empty)" placeholder for an empty expression.',
110
+ 'Renders ExpressionField in Text mode with an empty expression AST — only the DPQL editor shows; the "Parsed" preview box stays hidden until there is a query to parse.',
99
111
  },
100
112
  },
101
113
  },
@@ -105,18 +117,10 @@ export const Empty: Story = {
105
117
  },
106
118
  async play({ canvasElement }) {
107
119
  const canvas = within(canvasElement);
108
- // The preview element is present and reads `(empty)` for an empty AST.
109
- await waitFor(
110
- () =>
111
- expect(
112
- canvasElement.querySelector('[data-testid="expression-preview"]')
113
- ).toBeInTheDocument(),
114
- { timeout: 6000 }
115
- );
116
- await waitFor(
117
- () => expect(canvas.getByTestId('expression-preview').textContent).toContain('(empty)'),
118
- { timeout: 6000 }
119
- );
120
+ await waitForLspIdle(canvasElement);
121
+ // An empty query has nothing to parse — no Parsed box.
122
+ await expect(canvas.queryByTestId('expression-preview')).toBeNull();
123
+ await expect(canvas.queryByText('Parsed')).toBeNull();
120
124
  },
121
125
  };
122
126
 
@@ -230,6 +234,7 @@ export const ViaFormEngineTextMode: Story = {
230
234
  () => expect(canvas.getByTestId('expression-preview').textContent).toContain('John'),
231
235
  { timeout: 10000 }
232
236
  );
237
+ await waitForLspIdle(canvasElement);
233
238
  },
234
239
  };
235
240
 
@@ -314,6 +319,7 @@ export const ViaFormEngineTextTyping: Story = {
314
319
  () => expect(canvas.getByTestId('expression-preview')).toBeInTheDocument(),
315
320
  { timeout: 10000 }
316
321
  );
322
+ await waitForLspIdle(canvasElement);
317
323
  },
318
324
  };
319
325
 
@@ -358,6 +364,7 @@ export const TextMode: Story = {
358
364
  () => expect(canvas.getByTestId('expression-preview').textContent).toContain('name'),
359
365
  { timeout: 6000 }
360
366
  );
367
+ await waitForLspIdle(canvasElement);
361
368
  },
362
369
  };
363
370
 
@@ -218,24 +218,29 @@ export const ExpressionField = memo(
218
218
  readOnly={readOnly}
219
219
  height='48px'
220
220
  />
221
- <ReqoreMessage intent='info' size='small' title='Parsed' flat opaque={false}>
222
- {preview.server ? (
223
- // Server rendering shown through a read-only DpqlEditor:
224
- // template-ref chips + LSP token colours; diagnostics off
225
- // (the rendering is readable text, not parseable DPQL).
226
- <div data-testid='expression-preview' style={{ width: '100%' }}>
227
- <DpqlEditor
228
- value={preview.text}
229
- onChange={noop}
230
- readOnly
231
- showDiagnostics={false}
232
- enableHover={false}
233
- />
234
- </div>
235
- ) : (
236
- <code data-testid='expression-preview'>{preview.text || '(empty)'}</code>
237
- )}
238
- </ReqoreMessage>
221
+ {/* An empty query has nothing to parse — the box appears once a
222
+ parse result exists rather than sitting there holding a
223
+ placeholder token. */}
224
+ {preview.text ? (
225
+ <ReqoreMessage intent='info' size='small' title='Parsed' flat opaque={false}>
226
+ {preview.server ? (
227
+ // Server rendering shown through a read-only DpqlEditor:
228
+ // template-ref chips + LSP token colours; diagnostics off
229
+ // (the rendering is readable text, not parseable DPQL).
230
+ <div data-testid='expression-preview' style={{ width: '100%' }}>
231
+ <DpqlEditor
232
+ value={preview.text}
233
+ onChange={noop}
234
+ readOnly
235
+ showDiagnostics={false}
236
+ enableHover={false}
237
+ />
238
+ </div>
239
+ ) : (
240
+ <code data-testid='expression-preview'>{preview.text}</code>
241
+ )}
242
+ </ReqoreMessage>
243
+ ) : null}
239
244
  </>
240
245
  ) : (
241
246
  <ExpressionBuilder
@@ -30,6 +30,7 @@ import { StringFormField } from './string/String';
30
30
  import { ArrayAutoField } from './array/ArrayAutoField';
31
31
  import { AutoFormField } from './auto/AutoFormField';
32
32
  import { ByteSizeFormField } from './byte-size/ByteSize';
33
+ import { TimeoutFormField } from './timeout/Timeout';
33
34
  import { UrlFormField } from './url/Url';
34
35
  import { SchemaDefinitionEditor } from './schema-definition';
35
36
  import { IDataSchemaDefinition } from './schema-definition/types';
@@ -44,6 +45,10 @@ const mapQorusTypeToFormFieldType = (type: string): TFormFieldType => {
44
45
  case 'richtext': return 'richtext';
45
46
  case 'bool': case 'boolean': return 'bool';
46
47
  case 'int': case 'integer': case 'float': case 'number': return 'int';
48
+ // `timeout` is an integer count of milliseconds (a connection's
49
+ // connect_timeout / timeout); without it the default below renders it
50
+ // as a long-string text box.
51
+ case 'timeout': return 'timeout';
47
52
  case 'date': return 'date';
48
53
  case 'file': return 'file';
49
54
  case 'rgbcolor': return 'rgbcolor';
@@ -173,6 +178,18 @@ export const FormField = <T extends TFormFieldType>({
173
178
  />
174
179
  );
175
180
 
181
+ case 'timeout':
182
+ return (
183
+ <TimeoutFormField
184
+ {...(fieldProps as any)}
185
+ value={value as number}
186
+ onChange={(val) => handleChange(val as TFormFieldValueType<T>)}
187
+ disabled={(rest as { disabled?: boolean }).disabled}
188
+ readOnly={readonly}
189
+ size={fieldSize}
190
+ />
191
+ );
192
+
176
193
  case 'int':
177
194
  case 'integer':
178
195
  case 'float':
@@ -395,6 +395,36 @@ export const File: Story = {
395
395
  },
396
396
  };
397
397
 
398
+ /**
399
+ * A connection's `connect_timeout` / `timeout` option, as the server emits it:
400
+ * `type: 'timeout'` holding an integer count of milliseconds.
401
+ */
402
+ export const Timeout: Story = {
403
+ args: {
404
+ defaultType: 'timeout',
405
+ defaultInternalType: 'timeout',
406
+ type: 'timeout',
407
+ value: 45000,
408
+ },
409
+ parameters: {
410
+ docs: {
411
+ description: {
412
+ story:
413
+ "Renders a connection's Connect Timeout option, which the server types as `timeout` and fills with 45000 milliseconds. The unit-aware timeout field shows it as 45 with the seconds unit selected.",
414
+ },
415
+ },
416
+ },
417
+ async play({ canvasElement }) {
418
+ const canvas = within(canvasElement);
419
+
420
+ const input = await canvas.findByDisplayValue('45');
421
+ await expect(input).toBeInTheDocument();
422
+ await expect(input).toHaveAttribute('type', 'number');
423
+ await expect(canvas.getByText('seconds')).toBeInTheDocument();
424
+ await expect(canvas.queryByText('Unknown type!')).not.toBeInTheDocument();
425
+ },
426
+ };
427
+
398
428
  export const AutoDoesNotChangeTypeBackToAuto: Story = {
399
429
  args: {
400
430
  defaultType: 'auto',
@@ -31,6 +31,7 @@ import { FieldAllowedValues, FieldAllowedValuesCheckGroup } from '../allowed-val
31
31
  import { ArrayAuto } from '../array/ArrayAuto';
32
32
  import BooleanFormField from '../boolean/Boolean';
33
33
  import { ByteSizeFormField } from '../byte-size/ByteSize';
34
+ import { TimeoutFormField } from '../timeout/Timeout';
34
35
  import ColorFormField from '../color/Color';
35
36
  import CronFormField from '../cron/Cron';
36
37
  import { DateFormField } from '../date/Date';
@@ -734,6 +735,24 @@ function AutoField<T = any>({
734
735
  value={value}
735
736
  />
736
737
  );
738
+ // A connection's `connect_timeout` / `timeout` arrive from
739
+ // `getCreateConnectionOptions` as `type: 'timeout'` with a bare-integer
740
+ // default (45000) and an "in milliseconds" description. The value stays
741
+ // that integer millisecond count — TimeoutFormField only adds the
742
+ // unit-aware display. Extras are trimmed the same way as `byte-size`
743
+ // below: `rest` carries `templates`, which would flip the inner
744
+ // NumberFormField into its templates-dropdown variant.
745
+ case 'timeout':
746
+ return (
747
+ <TimeoutFormField
748
+ value={value}
749
+ onChange={(val) => handleChange(name, val)}
750
+ disabled={rest.disabled}
751
+ readOnly={rest.readonly}
752
+ size={rest.size}
753
+ aria-label={rest['aria-label']}
754
+ />
755
+ );
737
756
  case 'byte-size':
738
757
  // The IDE spreads `{...rest}` here but its ByteSizeField ignores
739
758
  // extras — reqraft's forwards them into the amount input, and `rest`
@@ -52,6 +52,32 @@ export const Items: Story = {
52
52
  },
53
53
  };
54
54
 
55
+ export const WithValueLabel: Story = {
56
+ args: {
57
+ value: 'seconds',
58
+ valueLabel: 's',
59
+ items: [{ value: 'ms' }, { value: 'seconds' }, { value: 'minutes' }],
60
+ },
61
+ parameters: {
62
+ docs: {
63
+ description: {
64
+ story:
65
+ 'Renders the Select field with a `valueLabel` override — the closed trigger shows the short "s" for the selected value, while opening the popover still lists the full item names.',
66
+ },
67
+ },
68
+ },
69
+ async play({ canvasElement }) {
70
+ const canvas = within(canvasElement);
71
+ await expect(canvas.getByText('s')).toBeInTheDocument();
72
+ await fireEvent.click(canvas.getByText('s'));
73
+ await waitFor(
74
+ () => expect(within(document.body).getByText('seconds')).toBeInTheDocument(),
75
+ { timeout: 5000 }
76
+ );
77
+ await expect(within(document.body).getByText('minutes')).toBeInTheDocument();
78
+ },
79
+ };
80
+
55
81
  export const ItemsWithDescription: Story = {
56
82
  parameters: {
57
83
  docs: {
@@ -31,6 +31,11 @@ export interface ISelectFormFieldProps {
31
31
  asMenu?: boolean;
32
32
  icon?: IReqoreIconName;
33
33
  showDescription?: 'tooltip' | boolean;
34
+ /** Label for the CLOSED trigger in place of the selected item's
35
+ * `display_name` — the open list keeps the item labels (e.g. a unit symbol
36
+ * on the trigger, full names in the picker). Ignored while nothing is
37
+ * selected. */
38
+ valueLabel?: string;
34
39
  showPlaceholder?: boolean;
35
40
  showRightIcon?: boolean;
36
41
  hideItemCount?: boolean;
@@ -69,6 +74,7 @@ export const SelectFormField = memo(
69
74
  icon,
70
75
  filters,
71
76
  showDescription = true,
77
+ valueLabel,
72
78
  showPlaceholder = true,
73
79
  showRightIcon = true,
74
80
  hideItemCount,
@@ -274,7 +280,7 @@ export const SelectFormField = memo(
274
280
  fluid={fluid}
275
281
  flat={flat ?? false}
276
282
  size={componentSize}
277
- label={getLabel(items, value ?? autoSelectedItem.value)}
283
+ label={valueLabel ?? getLabel(items, value ?? autoSelectedItem.value)}
278
284
  description={getItemShortDescription(value as string) as string}
279
285
  readOnly
280
286
  fixed
@@ -366,7 +372,7 @@ export const SelectFormField = memo(
366
372
  disabled={disabled}
367
373
  >
368
374
  {value ?
369
- getLabel(items, value as string)
375
+ (valueLabel ?? getLabel(items, value as string))
370
376
  : showPlaceholder ?
371
377
  placeholder || 'Please select'
372
378
  : undefined}
@@ -402,7 +408,7 @@ export const SelectFormField = memo(
402
408
  }
403
409
  >
404
410
  {value ?
405
- getLabel(items, value as string)
411
+ (valueLabel ?? getLabel(items, value as string))
406
412
  : showPlaceholder ?
407
413
  placeholder || 'Please select'
408
414
  : undefined}
@@ -0,0 +1,228 @@
1
+ import { StoryObj } from '@storybook/react-vite';
2
+ import { expect, fireEvent, fn, userEvent, waitFor, within } from 'storybook/test';
3
+ import { useState } from 'react';
4
+ import { StoryMeta } from '../../../../types';
5
+ import { TimeoutFormField } from './Timeout';
6
+
7
+ const meta = {
8
+ component: TimeoutFormField,
9
+ title: 'Components/Form/Timeout',
10
+ args: {
11
+ onChange: fn(),
12
+ 'aria-label': 'Timeout amount',
13
+ },
14
+ render(args) {
15
+ const [value, setValue] = useState(args.value);
16
+ return (
17
+ <TimeoutFormField
18
+ {...args}
19
+ value={value}
20
+ onChange={(v) => {
21
+ args.onChange?.(v);
22
+ setValue(v);
23
+ }}
24
+ />
25
+ );
26
+ },
27
+ } as StoryMeta<typeof TimeoutFormField>;
28
+
29
+ export default meta;
30
+ type Story = StoryObj<typeof meta>;
31
+
32
+ export const Default: Story = {
33
+ parameters: {
34
+ docs: {
35
+ description: {
36
+ story:
37
+ 'Renders the Timeout field with no value — the unit selector sits on ms. Typing a number fires onChange with that many milliseconds.',
38
+ },
39
+ },
40
+ },
41
+ play: async ({ canvasElement, args }) => {
42
+ const canvas = within(canvasElement);
43
+ const input = canvas.getByLabelText('Timeout amount');
44
+ await expect(input).toBeInTheDocument();
45
+ await expect(canvas.getByText('ms')).toBeInTheDocument();
46
+ await userEvent.type(input, '42');
47
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(42), { timeout: 5000 });
48
+ },
49
+ };
50
+
51
+ export const WithValue: Story = {
52
+ args: {
53
+ value: 45000,
54
+ },
55
+ parameters: {
56
+ docs: {
57
+ description: {
58
+ story:
59
+ 'Renders the Timeout field pre-populated with 45000 milliseconds — shown as 45 with the seconds unit selected, the largest unit that represents the value exactly.',
60
+ },
61
+ },
62
+ },
63
+ play: async ({ canvasElement }) => {
64
+ const canvas = within(canvasElement);
65
+ await expect(await canvas.findByDisplayValue('45')).toBeInTheDocument();
66
+ await expect(canvas.getByText('seconds')).toBeInTheDocument();
67
+ },
68
+ };
69
+
70
+ export const Mobile: Story = {
71
+ args: {
72
+ value: 45000,
73
+ // The phone presentation is prop-forced so the play is deterministic at
74
+ // any test-browser width; without the prop the field switches on the
75
+ // viewport's own phone-width flag.
76
+ abbreviateUnits: true,
77
+ },
78
+ parameters: {
79
+ // Capture at phone size; the selector abbreviation is prop-forced above.
80
+ qlip: { viewport: { width: 390, height: 844 } },
81
+ docs: {
82
+ description: {
83
+ story:
84
+ 'Renders the phone presentation of the Timeout field with 45000 milliseconds — the closed unit selector reads "s", while the open picker keeps the full seconds/minutes/hours names.',
85
+ },
86
+ },
87
+ },
88
+ play: async ({ canvasElement }) => {
89
+ const canvas = within(canvasElement);
90
+ await expect(await canvas.findByDisplayValue('45')).toBeInTheDocument();
91
+ await fireEvent.click(canvas.getByText('s'));
92
+ const body = within(canvasElement.ownerDocument.body);
93
+ await waitFor(() => expect(body.getByText('minutes')).toBeInTheDocument(), { timeout: 5000 });
94
+ await expect(body.getByText('hours')).toBeInTheDocument();
95
+ },
96
+ };
97
+
98
+ export const WithInexactValue: Story = {
99
+ args: {
100
+ value: 90500,
101
+ },
102
+ parameters: {
103
+ docs: {
104
+ description: {
105
+ story:
106
+ 'Renders the Timeout field with 90500 milliseconds — no larger unit divides it evenly, so it stays 90500 on the ms unit instead of rounding to 90.5 seconds.',
107
+ },
108
+ },
109
+ },
110
+ play: async ({ canvasElement }) => {
111
+ const canvas = within(canvasElement);
112
+ await expect(await canvas.findByDisplayValue('90500')).toBeInTheDocument();
113
+ await expect(canvas.getByText('ms')).toBeInTheDocument();
114
+ },
115
+ };
116
+
117
+ export const UnitSwitch: Story = {
118
+ args: {
119
+ value: 45000,
120
+ },
121
+ parameters: {
122
+ docs: {
123
+ description: {
124
+ story:
125
+ 'Renders the Timeout field with 45000 milliseconds (45 seconds). Switching the unit to minutes re-interprets the shown amount and fires onChange with 45 minutes in milliseconds.',
126
+ },
127
+ },
128
+ },
129
+ play: async ({ canvasElement, args }) => {
130
+ const canvas = within(canvasElement);
131
+ await fireEvent.click(await canvas.findByText('seconds'));
132
+ const body = within(canvasElement.ownerDocument.body);
133
+ await waitFor(() => expect(body.getByText('minutes')).toBeInTheDocument(), { timeout: 5000 });
134
+ await fireEvent.click(body.getByText('minutes'));
135
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(2700000), { timeout: 5000 });
136
+ await expect(canvas.getByDisplayValue('45')).toBeInTheDocument();
137
+ },
138
+ };
139
+
140
+ export const TypingConverts: Story = {
141
+ args: {
142
+ value: 45000,
143
+ },
144
+ parameters: {
145
+ docs: {
146
+ description: {
147
+ story:
148
+ 'Renders the Timeout field with 45000 milliseconds (45 seconds). Clearing the amount and typing 90 fires onChange with 90 seconds converted to milliseconds.',
149
+ },
150
+ },
151
+ },
152
+ play: async ({ canvasElement, args }) => {
153
+ const canvas = within(canvasElement);
154
+ const input = await canvas.findByDisplayValue('45');
155
+ await userEvent.clear(input);
156
+ await userEvent.type(input, '90');
157
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(90000), { timeout: 5000 });
158
+ await expect(canvas.getByText('seconds')).toBeInTheDocument();
159
+ },
160
+ };
161
+
162
+ export const KeepsUnitWhileTyping: Story = {
163
+ args: {
164
+ value: 45000,
165
+ },
166
+ parameters: {
167
+ docs: {
168
+ description: {
169
+ story:
170
+ 'Renders the Timeout field with 45 seconds. Typing 120 — a minute-exact amount — keeps the seconds unit instead of auto-promoting to 2 minutes, and emits 120 seconds in milliseconds.',
171
+ },
172
+ },
173
+ },
174
+ play: async ({ canvasElement, args }) => {
175
+ const canvas = within(canvasElement);
176
+ const input = await canvas.findByDisplayValue('45');
177
+ await userEvent.clear(input);
178
+ await userEvent.type(input, '120');
179
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(120000), { timeout: 5000 });
180
+ await expect(canvas.getByDisplayValue('120')).toBeInTheDocument();
181
+ await expect(canvas.getByText('seconds')).toBeInTheDocument();
182
+ },
183
+ };
184
+
185
+ export const ClearResetsToZero: Story = {
186
+ args: {
187
+ value: 45000,
188
+ },
189
+ parameters: {
190
+ docs: {
191
+ description: {
192
+ story:
193
+ "Renders the Timeout field with 45 seconds. Clicking the amount input's clear icon resets the value to 0 milliseconds while the seconds unit stays selected.",
194
+ },
195
+ },
196
+ },
197
+ play: async ({ canvasElement, args }) => {
198
+ const canvas = within(canvasElement);
199
+ await canvas.findByDisplayValue('45');
200
+ const clearButton = canvasElement.querySelector('.reqore-clear-input-button');
201
+ await expect(clearButton).toBeInTheDocument();
202
+ await fireEvent.click(clearButton!);
203
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(0), { timeout: 5000 });
204
+ await expect(canvas.getByDisplayValue('0')).toBeInTheDocument();
205
+ await expect(canvas.getByText('seconds')).toBeInTheDocument();
206
+ },
207
+ };
208
+
209
+ export const ReadOnly: Story = {
210
+ args: {
211
+ value: 45000,
212
+ readOnly: true,
213
+ },
214
+ parameters: {
215
+ docs: {
216
+ description: {
217
+ story:
218
+ 'Renders the Timeout field with 45 seconds in read-only mode — the amount input is marked readonly and the unit selector is disabled.',
219
+ },
220
+ },
221
+ },
222
+ play: async ({ canvasElement }) => {
223
+ const canvas = within(canvasElement);
224
+ const input = await canvas.findByDisplayValue('45');
225
+ await expect(input).toHaveAttribute('readonly');
226
+ await expect(canvas.getByText('seconds').closest('button')).toBeDisabled();
227
+ },
228
+ };
@@ -0,0 +1,87 @@
1
+ import { ReqoreControlGroup, useReqoreProperty } from '@qoretechnologies/reqore';
2
+ import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
3
+ import { memo, useState } from 'react';
4
+ import {
5
+ getLargestExactTimeoutUnit,
6
+ TIMEOUT_UNITS,
7
+ timeoutUnitSymbol,
8
+ timeoutUnitToMs,
9
+ TTimeoutUnit,
10
+ } from '../../../../helpers/common';
11
+ import NumberFormField from '../number/Number';
12
+ import { SelectFormField } from '../select/Select';
13
+
14
+ const UNIT_ITEMS = TIMEOUT_UNITS.map(({ value }) => ({ value }));
15
+
16
+ export interface ITimeoutFormFieldProps {
17
+ /** Integer count of milliseconds — the shape the server stores. */
18
+ value?: number;
19
+ onChange?: (value?: number) => void;
20
+ disabled?: boolean;
21
+ readOnly?: boolean;
22
+ size?: TSizes;
23
+ /** Show unit symbols (ms/s/m/h) instead of full names. Defaults to the
24
+ * viewport's phone-width flag. */
25
+ abbreviateUnits?: boolean;
26
+ /** Unknown props (`aria-label`, …) forward to the primary (amount) input. */
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ /**
31
+ * A timeout input — a numeric amount stacked with a unit selector
32
+ * (ms/seconds/minutes/hours). The field value stays the integer millisecond
33
+ * count the server stores; the unit only scales the display, and changing it
34
+ * re-interprets the shown amount (45 seconds → 45 minutes).
35
+ */
36
+ export const TimeoutFormField = memo(
37
+ ({ value, onChange, disabled, readOnly, size, abbreviateUnits, ...rest }: ITimeoutFormFieldProps) => {
38
+ const isMobile = useReqoreProperty('isMobile');
39
+ const shortUnits = abbreviateUnits ?? isMobile;
40
+ // The ms value alone can't say whether 45000 should read "45 seconds" or
41
+ // "45000 ms", so the unit is pinned once the user touches the field.
42
+ // Until then — and whenever the controlled value stops dividing evenly by
43
+ // the pin (a parent-side reset) — the largest exact unit wins, so the
44
+ // display never rounds.
45
+ const [pinnedUnit, setPinnedUnit] = useState<TTimeoutUnit>();
46
+ const unit =
47
+ pinnedUnit && (value == null || value % timeoutUnitToMs(pinnedUnit) === 0)
48
+ ? pinnedUnit
49
+ : getLargestExactTimeoutUnit(value);
50
+ const amount = value != null ? value / timeoutUnitToMs(unit) : undefined;
51
+
52
+ return (
53
+ <ReqoreControlGroup stack fluid size={size}>
54
+ <NumberFormField
55
+ {...(rest as any)}
56
+ fluid
57
+ type='int'
58
+ value={amount}
59
+ onChange={(next) => {
60
+ setPinnedUnit(unit);
61
+ onChange?.(typeof next === 'number' ? next * timeoutUnitToMs(unit) : undefined);
62
+ }}
63
+ disabled={disabled}
64
+ readOnly={readOnly}
65
+ />
66
+ <SelectFormField
67
+ fixed
68
+ hideItemCount
69
+ items={UNIT_ITEMS}
70
+ // Where space is tight only the trigger abbreviates — the open
71
+ // picker keeps the full unit names.
72
+ valueLabel={shortUnits ? timeoutUnitSymbol(unit) : undefined}
73
+ value={unit}
74
+ onChange={(next) => {
75
+ setPinnedUnit(next as TTimeoutUnit);
76
+ if (amount != null) {
77
+ onChange?.(amount * timeoutUnitToMs(next as TTimeoutUnit));
78
+ }
79
+ }}
80
+ disabled={disabled || readOnly}
81
+ />
82
+ </ReqoreControlGroup>
83
+ );
84
+ }
85
+ );
86
+
87
+ export default TimeoutFormField;
@@ -4,6 +4,7 @@ export * from './fields/array/ArrayAuto';
4
4
  export * from './fields/array/ArrayAutoField';
5
5
  export * from './fields/auto/AutoFormField';
6
6
  export * from './fields/byte-size/ByteSize';
7
+ export * from './fields/timeout/Timeout';
7
8
  export * from './fields/url/Url';
8
9
  export * from './fields/schema-definition';
9
10
  export * from './fields/boolean/Boolean';