@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.
Files changed (56) hide show
  1. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  2. package/dist/components/form/engine/CompactRow.js +2 -0
  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 +2 -0
  42. package/src/components/form/engine/FormEngine.tsx +10 -1
  43. package/src/components/form/engine/readFirst.ts +10 -3
  44. package/src/components/form/expressions/ExpressionField.stories.tsx +23 -16
  45. package/src/components/form/expressions/ExpressionField.tsx +23 -18
  46. package/src/components/form/fields/Field.tsx +17 -0
  47. package/src/components/form/fields/auto/AutoFormField.stories.tsx +30 -0
  48. package/src/components/form/fields/auto/AutoFormField.tsx +19 -0
  49. package/src/components/form/fields/select/Select.stories.tsx +26 -0
  50. package/src/components/form/fields/select/Select.tsx +9 -3
  51. package/src/components/form/fields/timeout/Timeout.stories.tsx +228 -0
  52. package/src/components/form/fields/timeout/Timeout.tsx +87 -0
  53. package/src/components/form/index.tsx +1 -0
  54. package/src/helpers/common.ts +36 -0
  55. package/src/helpers/validations.ts +49 -13
  56. 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';
@@ -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
- const nameValue = value?.name?.value ?? value?.name;
266
- const contentValue = value?.content?.value ?? value?.content;
267
- const nameResult = validateFieldWithResult('string', nameValue);
268
- const contentResult = validateFieldWithResult('string', contentValue);
269
-
270
- if (!nameResult.isValid) {
271
- return withContext(nameResult, 'File name is invalid');
272
- }
273
-
274
- if (!contentResult.isValid) {
275
- return withContext(contentResult, 'File content is invalid');
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
- return validResult();
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>