@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.
- package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +24 -7
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +11 -1
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +8 -2
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
- package/dist/components/form/expressions/ExpressionField.js +2 -2
- package/dist/components/form/expressions/ExpressionField.js.map +1 -1
- package/dist/components/form/fields/Field.d.ts.map +1 -1
- package/dist/components/form/fields/Field.js +7 -0
- package/dist/components/form/fields/Field.js.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.js +10 -0
- package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
- package/dist/components/form/fields/select/Select.d.ts +6 -1
- package/dist/components/form/fields/select/Select.d.ts.map +1 -1
- package/dist/components/form/fields/select/Select.js +5 -5
- package/dist/components/form/fields/select/Select.js.map +1 -1
- package/dist/components/form/fields/timeout/Timeout.d.ts +24 -0
- package/dist/components/form/fields/timeout/Timeout.d.ts.map +1 -0
- package/dist/components/form/fields/timeout/Timeout.js +73 -0
- package/dist/components/form/fields/timeout/Timeout.js.map +1 -0
- package/dist/components/form/index.d.ts +1 -0
- package/dist/components/form/index.d.ts.map +1 -1
- package/dist/components/form/index.js +1 -0
- package/dist/components/form/index.js.map +1 -1
- package/dist/helpers/common.d.ts +17 -0
- package/dist/helpers/common.d.ts.map +1 -1
- package/dist/helpers/common.js +42 -1
- package/dist/helpers/common.js.map +1 -1
- package/dist/helpers/validations.d.ts.map +1 -1
- package/dist/helpers/validations.js +50 -20
- package/dist/helpers/validations.js.map +1 -1
- package/dist/types/Form.d.ts +2 -2
- package/dist/types/Form.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +43 -20
- package/src/components/form/engine/FormEngine.stories.tsx +17 -11
- package/src/components/form/engine/FormEngine.tsx +10 -1
- package/src/components/form/engine/readFirst.ts +10 -3
- package/src/components/form/expressions/ExpressionField.stories.tsx +23 -16
- package/src/components/form/expressions/ExpressionField.tsx +23 -18
- package/src/components/form/fields/Field.tsx +17 -0
- package/src/components/form/fields/auto/AutoFormField.stories.tsx +30 -0
- package/src/components/form/fields/auto/AutoFormField.tsx +19 -0
- package/src/components/form/fields/select/Select.stories.tsx +26 -0
- package/src/components/form/fields/select/Select.tsx +9 -3
- package/src/components/form/fields/timeout/Timeout.stories.tsx +228 -0
- package/src/components/form/fields/timeout/Timeout.tsx +87 -0
- package/src/components/form/index.tsx +1 -0
- package/src/helpers/common.ts +36 -0
- package/src/helpers/validations.ts +49 -13
- 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 —
|
|
86
|
-
*
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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';
|