@qoretechnologies/reqraft 0.10.27 → 0.10.28
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 +2 -0
- 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 +2 -0
- 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
|
@@ -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';
|
|
@@ -726,6 +727,24 @@ function AutoField<T = any>({
|
|
|
726
727
|
value={value}
|
|
727
728
|
/>
|
|
728
729
|
);
|
|
730
|
+
// A connection's `connect_timeout` / `timeout` arrive from
|
|
731
|
+
// `getCreateConnectionOptions` as `type: 'timeout'` with a bare-integer
|
|
732
|
+
// default (45000) and an "in milliseconds" description. The value stays
|
|
733
|
+
// that integer millisecond count — TimeoutFormField only adds the
|
|
734
|
+
// unit-aware display. Extras are trimmed the same way as `byte-size`
|
|
735
|
+
// below: `rest` carries `templates`, which would flip the inner
|
|
736
|
+
// NumberFormField into its templates-dropdown variant.
|
|
737
|
+
case 'timeout':
|
|
738
|
+
return (
|
|
739
|
+
<TimeoutFormField
|
|
740
|
+
value={value}
|
|
741
|
+
onChange={(val) => handleChange(name, val)}
|
|
742
|
+
disabled={rest.disabled}
|
|
743
|
+
readOnly={rest.readonly}
|
|
744
|
+
size={rest.size}
|
|
745
|
+
aria-label={rest['aria-label']}
|
|
746
|
+
/>
|
|
747
|
+
);
|
|
729
748
|
case 'byte-size':
|
|
730
749
|
// The IDE spreads `{...rest}` here but its ByteSizeField ignores
|
|
731
750
|
// 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';
|
package/src/helpers/common.ts
CHANGED
|
@@ -174,6 +174,42 @@ export const splitByteSize = (value?: string): [number | undefined, string | und
|
|
|
174
174
|
return [amount?.[0] ? Number(amount[0]) : undefined, unit?.[0]];
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
+
export type TTimeoutUnit = 'ms' | 'seconds' | 'minutes' | 'hours';
|
|
178
|
+
|
|
179
|
+
/** Timeout display units, smallest → largest, with the ms multiplier each one
|
|
180
|
+
* scales by and the symbol shown where space is tight (phone-width selector).
|
|
181
|
+
* The stored value is always the integer millisecond count. */
|
|
182
|
+
export const TIMEOUT_UNITS: { value: TTimeoutUnit; ms: number; short: string }[] = [
|
|
183
|
+
{ value: 'ms', ms: 1, short: 'ms' },
|
|
184
|
+
{ value: 'seconds', ms: 1000, short: 's' },
|
|
185
|
+
{ value: 'minutes', ms: 60000, short: 'm' },
|
|
186
|
+
{ value: 'hours', ms: 3600000, short: 'h' },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
export const timeoutUnitToMs = (unit: TTimeoutUnit): number =>
|
|
190
|
+
TIMEOUT_UNITS.find(({ value }) => value === unit)!.ms;
|
|
191
|
+
|
|
192
|
+
export const timeoutUnitSymbol = (unit: TTimeoutUnit): string =>
|
|
193
|
+
TIMEOUT_UNITS.find(({ value }) => value === unit)!.short;
|
|
194
|
+
|
|
195
|
+
/** The largest unit that represents `value` without rounding (45000 →
|
|
196
|
+
* seconds, 90500 → ms). Zero divides evenly by everything, so it stays ms. */
|
|
197
|
+
export const getLargestExactTimeoutUnit = (value?: number): TTimeoutUnit => {
|
|
198
|
+
for (let i = TIMEOUT_UNITS.length - 1; i >= 0; i--) {
|
|
199
|
+
if (value != null && value !== 0 && value % TIMEOUT_UNITS[i].ms === 0) {
|
|
200
|
+
return TIMEOUT_UNITS[i].value;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return 'ms';
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Human-readable timeout (e.g. `"45 seconds"`) — scaled to the largest unit
|
|
207
|
+
* that represents the millisecond count exactly (90500 → `"90500 ms"`). */
|
|
208
|
+
export const formatTimeoutValue = (ms: number): string => {
|
|
209
|
+
const unit = getLargestExactTimeoutUnit(ms);
|
|
210
|
+
return `${ms / timeoutUnitToMs(unit)} ${unit}`;
|
|
211
|
+
};
|
|
212
|
+
|
|
177
213
|
/** Human-readable file size, e.g. `"12.3 KB"` — whole bytes below 1 KiB, one
|
|
178
214
|
* decimal above. Shared by the ticket thread / references attachment rows. */
|
|
179
215
|
export const formatBytes = (bytes: number): string => {
|
|
@@ -262,20 +262,45 @@ export const _validateField = (
|
|
|
262
262
|
case 'file': {
|
|
263
263
|
// Support both the Build-tab typed format ({ name: { type, value } })
|
|
264
264
|
// and the Upload-tab flat format ({ name: 'file.txt' })
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
265
|
+
//
|
|
266
|
+
// A Build-tab sub-value is a full option envelope: it carries its own
|
|
267
|
+
// `type` and may hold an expression (`is_expression`) or a rich-text
|
|
268
|
+
// paragraph array rather than a bare string. This used to unwrap `.value`
|
|
269
|
+
// and validate it as a hard-coded `string` with no field, discarding both
|
|
270
|
+
// the declared type and the expression flag — so a `concat(...)` filename
|
|
271
|
+
// arrived at the string validator as `{ exp, args }`, rich text arrived as
|
|
272
|
+
// an array, and both were rejected with "Value must be a non-empty text".
|
|
273
|
+
//
|
|
274
|
+
// Validate each part against its OWN type and route `is_expression`
|
|
275
|
+
// through `isFunction`, exactly as the `system-options` recursion below
|
|
276
|
+
// does, so the `field?.isFunction` branch above gets a chance to run.
|
|
277
|
+
const validateFilePart = (part: any, fallbackType: string): IValidationResult => {
|
|
278
|
+
const isEnvelope = !!part && isObject(part) && ('value' in part || 'is_expression' in part);
|
|
279
|
+
|
|
280
|
+
return isEnvelope
|
|
281
|
+
? validateFieldWithResult(part.type || fallbackType, part.value, {
|
|
282
|
+
isFunction: part.is_expression,
|
|
283
|
+
} as IFieldValidationProps)
|
|
284
|
+
: validateFieldWithResult(fallbackType, part);
|
|
285
|
+
};
|
|
277
286
|
|
|
278
|
-
|
|
287
|
+
// Fall back to the arg_schema's declared types for the flat Upload-tab
|
|
288
|
+
// shape: `name` is `richtext` (which also accepts a plain string) and
|
|
289
|
+
// `content` is `data`.
|
|
290
|
+
const nameResult = validateFilePart(value?.name, 'richtext');
|
|
291
|
+
const contentResult = validateFilePart(value?.content, 'data');
|
|
292
|
+
|
|
293
|
+
// Report both parts. Returning on the first failure meant a bad file name
|
|
294
|
+
// masked the content result entirely, so the form could only ever surface
|
|
295
|
+
// one problem at a time.
|
|
296
|
+
const reasons = [
|
|
297
|
+
...(nameResult.isValid ? [] : withContext(nameResult, 'File name is invalid').reasons),
|
|
298
|
+
...(contentResult.isValid
|
|
299
|
+
? []
|
|
300
|
+
: withContext(contentResult, 'File content is invalid').reasons),
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
return reasons.length ? invalidResult(reasons) : validResult();
|
|
279
304
|
}
|
|
280
305
|
case 'number': {
|
|
281
306
|
return resultFromBoolean(
|
|
@@ -504,6 +529,17 @@ export const _validateField = (
|
|
|
504
529
|
|
|
505
530
|
return validResult();
|
|
506
531
|
}
|
|
532
|
+
case 'timeout': {
|
|
533
|
+
// An integer count of milliseconds — the unit selector is display-only,
|
|
534
|
+
// so the stored value validates as an int.
|
|
535
|
+
const msResult = validateFieldWithResult('int', value);
|
|
536
|
+
|
|
537
|
+
if (!msResult.isValid) {
|
|
538
|
+
return withContext(msResult, 'Timeout must be a whole number of milliseconds');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return validResult();
|
|
542
|
+
}
|
|
507
543
|
case 'url': {
|
|
508
544
|
const protocolResult = validateFieldWithResult('string', getProtocol(value));
|
|
509
545
|
|
package/src/types/Form.ts
CHANGED
|
@@ -8,12 +8,15 @@ export type TFormFieldType =
|
|
|
8
8
|
| TQorusType
|
|
9
9
|
| 'softint' | 'softstring' | 'softbool' | 'softfloat' | 'softnumber' | 'softdate' | 'softlist'
|
|
10
10
|
| 'byte-size'
|
|
11
|
+
// the server emits this for a connection's connect_timeout / timeout;
|
|
12
|
+
// it is not (yet) in ts-toolkit's TQorusType
|
|
13
|
+
| 'timeout'
|
|
11
14
|
| 'multi-select'
|
|
12
15
|
| 'schema-definition';
|
|
13
16
|
|
|
14
17
|
export type TFormFieldValueType<T> =
|
|
15
18
|
T extends 'string' | 'long-string' | 'binary' | 'email' | 'url' | 'enum' | 'select-string' | 'file-as-string' | 'byte-size' ? string
|
|
16
|
-
: T extends 'int' | 'integer' | 'float' | 'number' ? number
|
|
19
|
+
: T extends 'int' | 'integer' | 'float' | 'number' | 'timeout' ? number
|
|
17
20
|
: T extends 'bool' | 'boolean' ? boolean
|
|
18
21
|
: T extends 'date' ? Date | string
|
|
19
22
|
: T extends 'hash' | 'free-hash' | 'data' ? Record<string, any>
|