diff --git a/.changeset/30178-numeric-screen-fields-refuse-strings.md b/.changeset/30178-numeric-screen-fields-refuse-strings.md new file mode 100644 index 00000000000..523e5bc7618 --- /dev/null +++ b/.changeset/30178-numeric-screen-fields-refuse-strings.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-automation': patch +'@object-ui/app-shell': patch +--- + +fix(automation)!: a `currency` or `percent` screen field refuses a non-number on resume, as a `number` field does, and the console renders `percent` as a numeric input (#30178) + +Clause-②: yes (narrowing) — `POST …/runs/RUN/resume` refuses a string under a `type: 'currency'` or `type: 'percent'` screen field, a bag it accepted before. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the screen-flow resume door. + +- **Before.** A `type: 'percent'` or `type: 'currency'` screen field took any string on resume: `"75"` under a `max` of 60, `"abc"` too. The bound compares numbers, so it never judged a string, and the run consumed its suspension on the value. The console rendered `percent` as a text input and posted a string, so a `percent` field's `min` / `max` held for no console user. +- **Now.** `number`, `currency` and `percent` are one value domain: a present value that is not a finite JSON number is refused with `invalid_type` inside `400 INVALID_SCREEN_INPUT`, the suspension is kept, and a corrected resume still lands. A numeric string is not read as the number it spells. The console renders `percent` as a numeric input carrying `min` / `max`, so it posts a number and the browser stops an over-limit value before any request. +- **Unchanged.** An absent value and a hidden field are not judged; `rating`, `slider`, `progress`, `text` and untyped fields still take a string; a JSON number on `currency` / `percent` is judged against the bound as before. +- **For a caller that skips the console.** The resume bag carries the JSON number (`60`), not a string; the refusal names the field and says it must be a number. No stored metadata changes, so there is nothing for `objectstack migrate meta` to rewrite. diff --git a/.changeset/30178-screen-field-numeric-kinds-contract-text.md b/.changeset/30178-screen-field-numeric-kinds-contract-text.md new file mode 100644 index 00000000000..3889a8161dd --- /dev/null +++ b/.changeset/30178-screen-field-numeric-kinds-contract-text.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +docs(spec)!: the screen-field contract names `number`, `currency` and `percent` as the numeric value domain the resume enforces (#30178) + +Clause-②: yes (narrowing) — the resume door refuses a string under a `type: 'currency'` or `type: 'percent'` screen field (`@objectstack/service-automation`); this package's `ScreenFieldSpec` and `ScreenFieldConfigSchema` text now states that rule. No schema shape changes. +Metadata-protocol: none + + + +**BREAKING** (described here, enforced in `@objectstack/service-automation`): a `currency` or `percent` screen field takes a finite JSON number on resume, as a `number` field does; a numeric string is refused with `invalid_type`, never coerced. `ScreenFieldSpec.type` / `.min` / `.max` and `ScreenFieldConfigSchema.type` / `.min` / `.max` say so, and the `type` describe text names the three kinds. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 95862d871a2..0598a241e4d 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -749,11 +749,12 @@ field's declared contract is the only contract behind it — there is no object schema to catch a bad bag downstream. That sentence carries no "if the value is a number" qualifier, because the -shape is checked first: on a `type: 'number'` field a submitted value that is -not a JSON number is **refused** on resume with `invalid_type`. `"25"` — the -same quantity as a *string* — is **not coerced** into `25` and does not reach -the bound; it is rejected as the wrong shape. The bound then compares numbers -only, which is all a bound can do. +shape is checked first: on a numeric field — `type: 'number'`, `'currency'` or +`'percent'` — a submitted value that is not a JSON number is **refused** on +resume with `invalid_type`. `"25"` — the same quantity as a *string* — is **not +coerced** into `25` and does not reach the bound; it is rejected as the wrong +shape. The bound then compares numbers only, which is all a bound can do. The +console renders all three kinds as a numeric input, which posts a number. **What these keys refuse:** @@ -784,7 +785,8 @@ only, which is all a bound can do. - **A bound on a non-numeric field.** A screen field's `type` has no closed vocabulary, so this schema cannot judge which types a bound is meaningful on. A bound on a `text` field constrains nothing and is not an error — the shape - check above reads `type: 'number'` alone, never the presence of a bound. + check above reads the three numeric kinds alone (not `rating`, `slider` or + `progress`), never the presence of a bound. - **An absent value.** A bound constrains a value that is present — an optional bounded field left empty is conformant. Presence is `required`'s question. - **A bound on a field the user was never shown.** Like `required`, a bound on a @@ -1302,8 +1304,9 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume ``` These conditions are refused, all reported at once and all with -`code: 'INVALID_SCREEN_INPUT'` (the value shapes of `type: 'number'` and -`type: 'file'`, and the `min` / `max` bounds, are described with those fields +`code: 'INVALID_SCREEN_INPUT'` (the value shapes of the numeric kinds +`number` / `currency` / `percent` and of `type: 'file'`, and the `min` / `max` +bounds, are described with those fields above): - a **`required` field the caller was actually asked for** is missing (an empty diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 2460f14d606..d2cea9969e1 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -289,7 +289,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | | **label** | `string` | optional | Display label | -| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input | +| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input | | **required** | `boolean` | optional | Whether a value is required to submit | | **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text | | **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused | @@ -311,7 +311,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` | | **label** | `string` | optional | Display label | -| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input | +| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input | | **required** | `boolean` | optional | Whether a value is required to submit | | **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text | | **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused | diff --git a/packages/services/service-automation/src/screen-input-contract.test.ts b/packages/services/service-automation/src/screen-input-contract.test.ts index bd4ef523f61..7cb08117705 100644 --- a/packages/services/service-automation/src/screen-input-contract.test.ts +++ b/packages/services/service-automation/src/screen-input-contract.test.ts @@ -89,8 +89,9 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => { }); it('reads only the closed value-domain members as value domains — every other widget hint stays open', () => { - // The closure is a closed set of members of an open vocabulary (`number`, - // and since #25801 `file` — `screen-input-file-field.test.ts`), not a new + // The closure is a closed set of members of an open vocabulary (the + // numeric kinds — `number`, and since #30178 `currency` / `percent`, pinned + // below — and since #25801 `file`, `screen-input-file-field.test.ts`), not a new // rule that every `type` constrains its value. A bound on a non-numeric // field still constrains nothing, exactly as `FieldSchema.min`/`.max` do. const texty: ScreenFieldSpec = { name: 'discount', type: 'text', min: 0, max: 20 }; @@ -126,3 +127,52 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => { expect(issues.map((i) => i.code).sort()).toEqual(['max_value', 'required', 'unknown_field']); }); }); + +// [#30178] `currency` and `percent` joined `number` in the value domain, by +// ruling, under ruling A′'s own rule: a present value that is not a finite JSON +// number is refused with `invalid_type`, ⛔ never coerced. Before it, a +// `percent` field (which the console rendered as text and posted as a string) +// and a `currency` field took ANY string — `"75"` under a `max` of 60, `"abc"` +// too — so the run consumed its suspension on a value the bound never judged. +describe('validateScreenInputs — `currency` and `percent` are numeric value domains (#30178)', () => { + for (const type of ['percent', 'currency'] as const) { + const field: ScreenFieldSpec = { name: 'discount', type, min: 0, max: 60 }; + const codes = (value: unknown, visibility: () => boolean | undefined = ALWAYS_VISIBLE) => + validateScreenInputs([field], { discount: value }, visibility).map((i) => i.code); + + it(`refuses a string on a \`${type}\` field — out of bound, in bound or not numeric at all`, () => { + // ⛔ Not coerced: `"75"` is refused for its SHAPE, not judged against the + // bound as the 75 it spells, and an in-bound `"60"` is refused the same. + for (const value of ['75', '60', 'abc']) { + expect(codes(value), `value ${JSON.stringify(value)}`).toEqual(['invalid_type']); + } + const [issue] = validateScreenInputs([field], { discount: '75' }, ALWAYS_VISIBLE); + expect(issue!.field).toBe('discount'); + expect(issue!.message).toContain('must be a number'); + }); + + it(`judges a number on a \`${type}\` field against its bound, endpoints included`, () => { + expect(codes(75)).toEqual(['max_value']); + expect(codes(-1)).toEqual(['min_value']); + for (const value of [0, 60]) expect(codes(value), `value ${value}`).toEqual([]); + }); + + it(`leaves an absent value and a hidden \`${type}\` field alone`, () => { + expect(codes(undefined)).toEqual([]); + expect(codes('')).toEqual([]); + const conditional: ScreenFieldSpec = { ...field, visibleWhen: 'wantsDiscount == true' }; + expect(validateScreenInputs([conditional], { discount: 'abc' }, () => false)).toEqual([]); + expect(validateScreenInputs([conditional], { discount: 'abc' }, () => undefined)).toEqual([]); + // …and the control, or the two readings above prove nothing. + expect(validateScreenInputs([conditional], { discount: 'abc' }, () => true).map((i) => i.code)) + .toEqual(['invalid_type']); + }); + } + + it('does not widen past the numeric kinds — `rating`, `slider`, `progress`, `text` and untyped still take a string', () => { + for (const type of ['rating', 'slider', 'progress', 'text', undefined]) { + const field: ScreenFieldSpec = { name: 'discount', type, min: 0, max: 60 }; + expect(validateScreenInputs([field], { discount: '75' }, ALWAYS_VISIBLE), `type ${String(type)}`).toEqual([]); + } + }); +}); diff --git a/packages/services/service-automation/src/screen-input-contract.ts b/packages/services/service-automation/src/screen-input-contract.ts index 95952de6646..c58902aa1f9 100644 --- a/packages/services/service-automation/src/screen-input-contract.ts +++ b/packages/services/service-automation/src/screen-input-contract.ts @@ -59,6 +59,11 @@ function isPresent(v: unknown): boolean { return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === ''); } +/** A finite JSON number: the one value the numeric screen-field kinds admit. */ +function isFiniteNumber(value: unknown): boolean { + return typeof value === 'number' && Number.isFinite(value); +} + /** * The closed set of screen-field `type` members that are VALUE domains, each * with the predicate a present value must meet and the sentence a value that @@ -67,7 +72,13 @@ function isPresent(v: unknown): boolean { */ const SCREEN_FIELD_VALUE_DOMAINS: ReadonlyMap boolean; expected: string }> = new Map([ - ['number', { admits: (value) => typeof value === 'number' && Number.isFinite(value), expected: 'a number' }], + // The three numeric kinds, on one predicate: the same group the object + // protocol names (`MEASURE_FIELD_TYPES`). `number` is ruling A′; `currency` + // and `percent` joined it by ruling (#30178). A finite JSON number only, ⛔ + // never a numeric string read as one. + ['number', { admits: isFiniteNumber, expected: 'a number' }], + ['currency', { admits: isFiniteNumber, expected: 'a number' }], + ['percent', { admits: isFiniteNumber, expected: 'a number' }], // [#25801] The uploaded `sys_file` id — ADR-0104 D3's stored form — judged // by the spec's one arbiter, the predicate the record write's claim path // reads too, so an id this door admits is one a later write can claim. @@ -111,8 +122,9 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo * * Enforced, and nothing beyond it: * - `required` presence for every field the caller was actually asked for; - * - the VALUE SHAPE of a `type: 'number'` field (#17306) and of a - * `type: 'file'` field (#25801); + * - the VALUE SHAPE of a numeric field — `type: 'number'` (#17306), + * `'currency'` or `'percent'` (#30178) — and of a `type: 'file'` field + * (#25801); * - the declared numeric bound pair `min` / `max` (#17306); * - no value at all under a `type: 'record_link'` field; * - undeclared keys. @@ -130,9 +142,12 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo * Value SHAPE (`type`) is out of scope here with ONE closed exception, and the * exception is the maintainer's (ruling A′, 2026-09-13): a screen field's * `type` is an open widget hint, so no member of it constrains a value — - * EXCEPT `'number'`, whose meaning is not a rendering preference but a value - * domain the bound pair beside it already assumes. A present value for a - * `type: 'number'` field that is not a finite JSON number is refused with + * EXCEPT the numeric kinds, whose meaning is not a rendering preference but a + * value domain the bound pair beside it already assumes. Ruling A′ named + * `'number'`; `'currency'` and `'percent'` joined it under the same rule by a + * later ruling (#30178), so the three numeric kinds the object protocol groups + * (`MEASURE_FIELD_TYPES`) answer one question one way. A present value for a + * numeric field that is not a finite JSON number is refused with * `invalid_type`, the catalog's existing member for a value of the wrong * primitive type (ADR-0114 D2 — no new code, and deliberately not * `invalid_number`, which names a PARSE failure rather than a type mismatch). @@ -143,9 +158,12 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo * That closes the gap the bound pass alone left open. The bound pass compares * numbers, so before this every non-number slipped past BOTH — under a `max` * of 20, `"25"` was conformant. The refusal now lands on the shape, and the - * bound pass is left comparing numbers only, which is all it can do. + * bound pass is left comparing numbers only, which is all it can do. Until + * `percent` joined, the console posted it as a string, so a `percent` field's + * bound held for no console user at all; the console now gives it the numeric + * input `number` and `currency` have. * - * ⚠️ Narrow on purpose in two directions. It keys off `type: 'number'`, not + * ⚠️ Narrow on purpose in two directions. It keys off the numeric `type`, not * off the presence of a bound: a bound declared on a non-numeric field still * constrains nothing (`FieldSchema.min`/`.max` are unconditioned on `type` * too). And it is a PRESENCE-conditioned check, like the bound: an absent @@ -167,7 +185,8 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo * write's question, asked there under the write's own caller (#25692) — not * this door's, which holds no storage service. * - * No other widget hint is read as a value domain: the set is + * No other widget hint is read as a value domain — `rating`, `slider` and + * `progress` included, which the console has no numeric input for: the set is * {@link SCREEN_FIELD_VALUE_DOMAINS}, and a member joins it by ruling. * * `'record_link'` is the one member that takes NO value (ruling B on the @@ -206,8 +225,9 @@ export function validateScreenInputs( }); } - // Value shape for a value-domain `type` — `'number'` (#17306, ruling A′) and - // `'file'` (#25801). Its own pass, BEFORE the bound: a value that is not a + // Value shape for a value-domain `type` — the numeric kinds (`'number'`, + // #17306 ruling A′; `'currency'` / `'percent'`, #30178) and `'file'` + // (#25801). Its own pass, BEFORE the bound: a value that is not a // number cannot be compared against one, so the shape is the first thing // true or false about it. A caller that posts `"25"` under a `max` of 20 gets // one issue naming the shape, not a silent pass and not a bound verdict diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index f11033877ec..444e8283cac 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -954,8 +954,10 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ * Input type — an open widget hint (`text`, `number`, `select`, …) the client * maps to a control. * - * Two members are also VALUE domains, which the resume enforces - * (`validateScreenInputs`): `'number'` (a finite JSON number, ruling A′) and + * Four members are also VALUE domains, which the resume enforces + * (`validateScreenInputs`): the three numeric kinds `'number'` (ruling A′), + * `'currency'` and `'percent'` (the same rule, by a later ruling) take a + * finite JSON number, a numeric string refused and ⛔ never coerced; and * `'file'` (#25801). A `'file'` field renders the platform upload widget; the * upload goes through the file service and lands as a `sys_file` no record * owns yet, and the resume carries that file's id — ADR-0104 D3's stored @@ -977,7 +979,8 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ * result screen that links the records the run created. */ type: z.string().optional().describe( - 'Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and ' + 'Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite ' + + 'number on resume (a numeric string is refused); `file` renders the platform upload widget and ' + "the screen resumes with the uploaded file's id, which a later node writes into a `file` field; " + '`record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a ' + 'link to its record page, and takes no input', @@ -1024,16 +1027,19 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ * directly, which is the gap #4477 closed for `required`. * * The resume check compares numbers, and a caller that posts a non-number no - * longer slips past it by never reaching it: on a `type: 'number'` field a - * present value that is not a finite JSON number is refused first - * (`invalid_type`), ⛔ not coerced (maintainer ruling A′, 2026-09-13). So + * longer slips past it by never reaching it: on a numeric field + * (`type: 'number'`, `'currency'` or `'percent'`) a present value that is + * not a finite JSON number is refused first (`invalid_type`), ⛔ not coerced + * (maintainer ruling A′, 2026-09-13; `currency` and `percent` by a later + * ruling under the same rule). So * "a caller that skips the dialog is refused too" holds with no qualifier. * * Unconditioned on `type` in the SHAPE, exactly as `FieldSchema.min` / * `.max` are: a screen field's `type` is an open widget hint, so this schema * cannot judge which types a bound is meaningful on. A bound declared on a * non-numeric field still constrains nothing the client renders — the shape - * refusal above reads `'number'` alone, not the presence of a bound. + * refusal above reads the three numeric kinds alone, not the presence of a + * bound. */ min: z.number().optional().describe('Minimum accepted value (numeric fields); enforced on resume'), max: z.number().optional().describe('Maximum accepted value (numeric fields); enforced on resume'), diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index fe842a6185a..a5d825bc543 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -352,9 +352,12 @@ export interface ScreenFieldSpec { * Widget hint (text/select/boolean/number/date/file/…); the client maps it * to a field widget. * - * Two members are value domains the server re-checks on resume - * (`validateScreenInputs`): `'number'` takes a finite JSON number, and - * `'file'` (#25801) takes the id of an uploaded `sys_file` — the client + * Four members are value domains the server re-checks on resume + * (`validateScreenInputs`): the three numeric kinds `'number'`, + * `'currency'` and `'percent'` take a finite JSON number (a numeric + * string is refused, ⛔ not coerced; the client renders all three as a + * numeric input, which posts a number), and `'file'` (#25801) takes the + * id of an uploaded `sys_file` — the client * renders the platform upload widget and answers with the id the upload * minted, never the expanded `{ id, name, url, … }` read form. * @@ -377,8 +380,9 @@ export interface ScreenFieldSpec { * Unlike {@link visibleWhen}, these are NOT client-only: the server * re-checks them when the run resumes (`validateScreenInputs`), so a bound * holds against a caller that skips the dialog — with no "if the value - * happens to be a number" qualifier, because on a `type: 'number'` field a - * present non-number is refused outright (`invalid_type`) rather than + * happens to be a number" qualifier, because on a numeric field + * (`type: 'number'`, `'currency'` or `'percent'`) a present non-number is + * refused outright (`invalid_type`) rather than * passing the bound it cannot be compared against. ⛔ Not coerced. The * client still applies them so the user is stopped at the input rather * than at Submit. diff --git a/ui/packages/app-shell/src/views/ScreenView.boundHelpReference-9248.test.tsx b/ui/packages/app-shell/src/views/ScreenView.boundHelpReference-9248.test.tsx index 659720da376..b7723741b20 100644 --- a/ui/packages/app-shell/src/views/ScreenView.boundHelpReference-9248.test.tsx +++ b/ui/packages/app-shell/src/views/ScreenView.boundHelpReference-9248.test.tsx @@ -63,6 +63,33 @@ describe('objectui#9248 — `min` / `max` land on the numeric input', () => { }); }); +// objectstack#30178 — the server refuses a non-number on a `percent` field, as +// on `number` and `currency`, so the console must post one: a `percent` field +// takes the numeric input, carries its bound, and hands the runner a number. +describe('objectstack#30178 — a `percent` field is a numeric input that submits a number', () => { + it('renders a number input carrying `min` / `max`, and a typed value reaches the runner as a number', () => { + const onValueChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('spinbutton', { name: 'Discount' }); + expect(input).toHaveAttribute('type', 'number'); + expect(input).toHaveAttribute('min', '0'); + expect(input).toHaveAttribute('max', '60'); + fireEvent.change(input, { target: { value: '75' } }); + expect(onValueChange).toHaveBeenLastCalledWith('discount', 75); + // …and the client bound check, which compares a present number, now sees one. + expect(screenFieldBoundViolations( + specOf([{ name: 'discount', type: 'percent', min: 0, max: 60 }]), + { discount: 75 }, + )).toHaveLength(1); + }); +}); + describe('objectui#9248 — `screenFieldBoundViolations` applies the engine resume rule', () => { const qty = { name: 'qty', label: 'Quantity', type: 'number', min: 1, max: 10 }; diff --git a/ui/packages/app-shell/src/views/ScreenView.tsx b/ui/packages/app-shell/src/views/ScreenView.tsx index c46054a18bb..82387513284 100644 --- a/ui/packages/app-shell/src/views/ScreenView.tsx +++ b/ui/packages/app-shell/src/views/ScreenView.tsx @@ -707,7 +707,12 @@ export function ScreenFieldInput({ if (kind === 'textarea' || kind === 'markdown') { return