From 1841ff3f0f8903c15bb74fd534990b57f06b4018 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Mon, 12 Oct 2026 14:18:36 -0700 Subject: [PATCH] fix(apollo-wind): QuickFormField expression mode for selects, and a disabled prop Single-select and multi-select now support expression mode like every other type, so the separate non-expression render path and the expression-to-literal coercion are removed. `locked` is now configuration only: it drives the lock toggle but no longer makes the value read-only or swaps the control for plain text. A new `disabled` prop disables the value control, lock, mode menu, and header controls instead. Custom mode controls receive `disabled` via the new exported QuickFormFieldControlProps type. FIELD_TYPE_META.supportsExpression (now always true) and the trueLabel/falseLabel strings are deprecated. The AI-assist output hint now follows the current mode rather than the type. Co-Authored-By: Claude Opus 5.5 --- .../src/patterns/FieldTypes.stories.tsx | 5 +- .../NodePropertyPanel.stories.tsx | 2 - .../components/field-header.tsx | 18 +- .../components/lock-toggle-button.tsx | 10 +- .../components/ui/quick-form-field/index.ts | 1 + .../quick-form-field.divergence.test.tsx | 54 +- .../quick-form-field.stories.tsx | 48 +- .../quick-form-field.test.tsx | 223 ++++---- .../ui/quick-form-field/quick-form-field.tsx | 538 ++++++++---------- .../components/ui/quick-form-field/types.ts | 79 +-- .../components/ui/quick-form-field/utils.ts | 58 +- packages/apollo-wind/src/index.ts | 1 + 12 files changed, 453 insertions(+), 584 deletions(-) diff --git a/apps/storybook/src/patterns/FieldTypes.stories.tsx b/apps/storybook/src/patterns/FieldTypes.stories.tsx index ec7cf12737..d9b503902d 100644 --- a/apps/storybook/src/patterns/FieldTypes.stories.tsx +++ b/apps/storybook/src/patterns/FieldTypes.stories.tsx @@ -1082,8 +1082,9 @@ function FieldTypesPage({ globalTheme }: { globalTheme: string }) { Lock.{' '} - locked and onLockedChange make the - value read-only for the person filling in the form, shown as plain text;{' '} + locked and onLockedChange mark the + value read-only for the person filling in the form. The author can still edit it;{' '} + disabled disables the controls, and{' '} showLock hides the toggle. diff --git a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx index c3e218273a..2b42eb6ded 100644 --- a/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx +++ b/packages/apollo-react/src/canvas/components/NodePropertyPanel/NodePropertyPanel.stories.tsx @@ -2440,7 +2440,6 @@ export function QuickFormPanel({ ...c, fieldType, value: '', - mode: FIELD_TYPE_META[fieldType].supportsExpression ? c.mode : 'literal', } : c ) @@ -3305,7 +3304,6 @@ function PanelUIInventoryStory() { const updateCompositionFieldType = (fieldType: QuickFieldType) => { setCompositionFieldType(fieldType); setCompositionValue(''); - if (!FIELD_TYPE_META[fieldType].supportsExpression) setCompositionMode('literal'); }; return ( diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/components/field-header.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/components/field-header.tsx index bfdabc7eea..63bf4a3cba 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/components/field-header.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/components/field-header.tsx @@ -33,6 +33,8 @@ export function FieldHeader({ onFieldTypeChange, onRequiredChange, compact, + disabled, + isExpression, showFieldActions, onValueChange, variables, @@ -51,6 +53,9 @@ export function FieldHeader({ onFieldTypeChange?: (fieldType: QuickFieldType) => void; onRequiredChange?: (required: boolean) => void; compact?: boolean; + disabled: boolean; + /** Whether the AI output is described as an expression rather than a value. */ + isExpression: boolean; showFieldActions: boolean; onValueChange?: (value: string) => void; variables: QuickFormFieldOption[]; @@ -111,8 +116,9 @@ export function FieldHeader({ @@ -192,6 +200,7 @@ export function FieldHeader({ size="sm" checked={!!required} onCheckedChange={onRequiredChange} + disabled={disabled} className="data-[state=checked]:bg-brand data-[state=unchecked]:bg-foreground-subtle" aria-label={ required ? strings.requiredAriaLabel : strings.optionalAriaLabel @@ -208,9 +217,8 @@ export function FieldHeader({ {onGenerateWithAi && ( void; + disabled?: boolean; /** See QuickFormFieldStrings. */ strings: QuickFormFieldStrings; }) { - const interactive = !!onLockedChange; + const interactive = !!onLockedChange && !disabled; return ( diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/index.ts b/packages/apollo-wind/src/components/ui/quick-form-field/index.ts index 74a34926f0..3a24a3707b 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/index.ts +++ b/packages/apollo-wind/src/components/ui/quick-form-field/index.ts @@ -1,6 +1,7 @@ export { QuickFormField } from './quick-form-field'; export type { QuickFieldType, + QuickFormFieldControlProps, QuickFormFieldMode, QuickFormFieldMoreActions, QuickFormFieldOption, diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx index 2fba2d234a..9bf83985fd 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.divergence.test.tsx @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { QuickFormField } from './quick-form-field'; import { FIELD_TYPE_META, FIELD_TYPE_ORDER } from './types'; -import { getLockedDisplayValue } from './utils'; /** * Covers the widenings added on top of the original component: the extra types @@ -108,24 +107,19 @@ describe('QuickFormField — divergences from apollo-wind', () => { ).toBeInTheDocument(); }); - // Locked fields show text in place of the control, so every added type - // needs a display form or it renders blank. - it('shows the time of day when a locked datetime is displayed', () => { - const shown = getLockedDisplayValue('datetime', '2024-01-15T13:45:00.000Z', []); - expect(shown).not.toBe(''); - expect(shown).not.toBe('2024-01-15T13:45:00.000Z'); - // A date-only render would drop the clock time entirely. - const dateOnly = getLockedDisplayValue('date', '2024-01-15', []); - expect(shown).not.toBe(dateOnly); - }); - - it('falls back to the raw value for an unparseable datetime rather than throwing', () => { - expect(getLockedDisplayValue('datetime', 'not-a-date', [])).toBe('not-a-date'); - expect(getLockedDisplayValue('datetime', '', [])).toBe(''); - }); - - it('treats an array as an expression-capable value, like object', () => { - expect(FIELD_TYPE_META.array.supportsExpression).toBe(true); + it('renders the expression input for every type in expression mode', () => { + for (const fieldType of FIELD_TYPE_ORDER) { + const { unmount } = render( + + ); + expect(screen.getByDisplayValue('$vars.x')).toHaveClass('font-mono'); + unmount(); + } }); }); @@ -140,23 +134,8 @@ describe('QuickFormField — divergences from apollo-wind', () => { } }); - // Only `expression` is coerced for a type that cannot hold one. - it('still pins a non-expression type out of expression mode', () => { - render( - - ); - expect(screen.getByDisplayValue('Option 1')).toBeInTheDocument(); - }); - - // The consumer is offered the raw mode, even one the built-in rendering - // would coerce away, since it may have its own editor. - it('offers a non-expression type its expression mode to the consumer', () => { + // A consumer's own editor replaces the built-in expression input for any type. + it('offers a select its expression mode to the consumer', () => { render( { expect(screen.getByText('consumer expression')).toBeInTheDocument(); }); - // ...but a select CAN be bound to a variable, so that mode must survive. - it('lets a non-expression type use a variable binding', () => { + it('lets a select use a variable binding', () => { render( { setFieldType(type); setValue(''); - if (!FIELD_TYPE_META[type].supportsExpression) { - setMode('literal'); - } }; if (deleted) return null; @@ -415,6 +407,33 @@ export const InlineValidation: Story = { }, }; +export const Disabled: Story = { + render: () => ( +
+ {}} + onValueChange={() => {}} + onModeChange={() => {}} + onFieldTypeChange={() => {}} + onRequiredChange={() => {}} + variables={[{ label: 'Item ID', value: 'item.id' }]} + disabled + /> +
+ ), + parameters: { + docs: { + description: { + story: + 'Use `disabled` to stop the author editing the field. It disables the value control, the lock, the value-mode menu, and the header controls. A locked field stays editable: the lock only marks the value read-only for the person filling in the form.', + }, + }, + }, +}; + const MODE_ITEMS: { mode: QuickFormFieldMode; label: string; icon: typeof Type }[] = [ { mode: 'literal', label: 'Fixed value', icon: Type }, { mode: 'expression', label: 'Expression', icon: Code2 }, @@ -690,9 +709,6 @@ function ResponsiveDemo() { const handleFieldTypeChange = (type: QuickFieldType) => { setFieldType(type); setValue(''); - if (!FIELD_TYPE_META[type].supportsExpression) { - setMode('literal'); - } }; const label = (fieldId: string) => ( diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx index f5c6966bb4..6b46356399 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.test.tsx @@ -5,10 +5,46 @@ import { describe, expect, it, vi } from 'vitest'; import { QuickFormField } from './quick-form-field'; describe('QuickFormField', () => { - it('renders a read-only display value when locked', () => { - render(); - expect(screen.getByPlaceholderText('String value')).toHaveValue('INV-2024-0587'); - expect(screen.getByPlaceholderText('String value')).toHaveAttribute('readonly'); + it('keeps the value editable when locked', () => { + const handleChange = vi.fn(); + render(); + const input = screen.getByPlaceholderText('String value'); + expect(input).not.toHaveAttribute('readonly'); + fireEvent.change(input, { target: { value: 'INV-2024-0588' } }); + expect(handleChange).toHaveBeenCalledWith('INV-2024-0588'); + }); + + it('disables every built-in control when disabled', () => { + render( + + ); + expect(screen.getByPlaceholderText('String value')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Read-only' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Choose value mode' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Field type' })).toBeDisabled(); + expect(screen.getByRole('switch', { name: 'Optional field' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'AI assist' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Insert variable' })).toBeDisabled(); + }); + + it.each([ + ['boolean', 'switch'], + ['single-select', 'combobox'], + ['multi-select', 'combobox'], + ] as const)('disables the %s control when disabled', (fieldType, role) => { + render(); + expect(screen.getByRole(role)).toBeDisabled(); }); it('renders an editable input when unlocked and onValueChange is provided', () => { @@ -95,12 +131,12 @@ describe('QuickFormField', () => { expect(handleBlur).toHaveBeenCalledOnce(); }); - it('withholds value changes from a consumer expression editor while locked', () => { + it('withholds value changes from a consumer expression editor while disabled', () => { const renderExpressionEditor = vi.fn(() =>
Custom editor
); render( { expect.objectContaining({ value: 'item.id', readOnly: true, + disabled: true, onValueChange: undefined, }) ); }); - it('does not attach a value-change handler to the built-in expression input while locked', () => { + it('does not attach a value-change handler to the built-in expression input while disabled', () => { const handleChange = vi.fn(); render( - + ); - fireEvent.change(screen.getByDisplayValue('item.id'), { target: { value: 'other.id' } }); + const input = screen.getByDisplayValue('item.id'); + expect(input).toBeDisabled(); + fireEvent.change(input, { target: { value: 'other.id' } }); expect(handleChange).not.toHaveBeenCalled(); }); @@ -191,49 +230,58 @@ describe('QuickFormField', () => { expect(screen.getByRole('button', { name: 'Insert variable' })).toBeInTheDocument(); }); - it('only shows the value mode menu for types that support expressions', () => { - const { rerender } = render(); - expect(screen.queryByRole('button', { name: 'Choose value mode' })).not.toBeInTheDocument(); - - rerender(); - expect(screen.getByRole('button', { name: 'Choose value mode' })).toBeInTheDocument(); - }); - - it('lists the More actions under the value mode menu', async () => { + it.each([ + 'single-select', + 'multi-select', + ] as const)('offers expression mode for a %s field', async (fieldType) => { const user = userEvent.setup(); + const handleModeChange = vi.fn(); render( ); await user.click(screen.getByRole('button', { name: 'Choose value mode' })); + await user.click(screen.getByRole('menuitemradio', { name: /Expression/ })); + expect(handleModeChange).toHaveBeenCalledWith('expression'); + }); - expect(screen.getByRole('menuitemradio', { name: /Fixed value/ })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Clear value' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'Force refresh' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'More value actions' })).not.toBeInTheDocument(); + it.each([ + 'single-select', + 'multi-select', + ] as const)('renders the expression input for a %s field in expression mode', (fieldType) => { + render( + + ); + expect(screen.getByDisplayValue('$vars.choice')).toHaveClass('font-mono'); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); }); - it('offers only the More actions for a type with no expression mode', async () => { + it('lists the More actions under the value mode menu', async () => { const user = userEvent.setup(); render( ); - expect(screen.queryByRole('button', { name: 'Choose value mode' })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'More value actions' })); + await user.click(screen.getByRole('button', { name: 'Choose value mode' })); + + expect(screen.getByRole('menuitemradio', { name: /Fixed value/ })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: 'Clear value' })).toBeInTheDocument(); - expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Force refresh' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'More value actions' })).not.toBeInTheDocument(); }); it('offers only the More actions when there is no mode handler', async () => { @@ -246,13 +294,13 @@ describe('QuickFormField', () => { expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument(); }); - it('only renders available More actions and hides Clear value when locked', async () => { + it('keeps Clear value available when locked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'More value actions' })); - expect(screen.queryByRole('menuitem', { name: 'Clear value' })).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Clear value' })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: 'Force refresh' })).toBeInTheDocument(); }); @@ -355,8 +403,10 @@ describe('QuickFormField', () => { expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'grow'); }); - it('keeps locked file fields in the compact read-only layout', () => { - const { container } = render(); + it('keeps a file field in expression mode in the row layout', () => { + const { container } = render( + + ); expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'row'); }); @@ -457,59 +507,43 @@ describe('QuickFormField', () => { expect(screen.getByRole('button', { name: 'Supporting document' })).toBeInTheDocument(); }); - it('ignores non-string entries when parsing a locked multi-select value', () => { + it('ignores non-string entries when parsing a multi-select value', () => { const options = [{ label: 'Alpha', value: 'alpha' }]; render( ); - expect(screen.getByPlaceholderText('Multi select value')).toHaveValue('Alpha'); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.queryByText('42')).not.toBeInTheDocument(); }); - it('falls back to the raw value for a locked multi-select value that parses to no entries', () => { - render(); - expect(screen.getByPlaceholderText('Multi select value')).toHaveValue('not-json'); - }); - - it('shows an empty display for a locked multi-select value that is an explicit empty array', () => { - render(); - expect(screen.getByPlaceholderText('Multi select value')).toHaveValue(''); - }); - - it('falls back to the raw value instead of throwing on an invalid date string', () => { + it('shows the raw value instead of throwing when unlocked with an invalid date', () => { expect(() => - render() + render() ).not.toThrow(); - expect(screen.getByPlaceholderText('Date value')).toHaveValue('not-a-date'); - }); - - it('shows the raw value instead of throwing when unlocked with an invalid date', () => { - const { container } = render( - - ); - expect(container).toHaveTextContent('not-a-date'); + expect(screen.getByRole('button', { name: 'Date value' })).toHaveTextContent('not-a-date'); }); it('rejects an out-of-range date-only value instead of silently normalizing it', () => { - render(); - expect(screen.getByPlaceholderText('Date value')).toHaveValue('2024-13-40'); + render(); + expect(screen.getByRole('button', { name: 'Date value' })).toHaveTextContent('2024-13-40'); }); it('formats a date-only value using the local calendar day, not UTC', () => { const originalTz = process.env.TZ; process.env.TZ = 'America/Los_Angeles'; try { - render(); + render(); const expected = new Date(2024, 0, 15).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', }); - expect(screen.getByPlaceholderText('Date value')).toHaveValue(expected); + expect(screen.getByRole('button', { name: 'Date value' })).toHaveTextContent(expected); } finally { if (originalTz === undefined) { delete process.env.TZ; @@ -545,24 +579,6 @@ describe('QuickFormField', () => { expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); }); - it('shows a consumer-supplied option label for a locked single-select value', () => { - const options = [{ label: 'Custom option', value: 'custom' }]; - render(); - expect(screen.getByPlaceholderText('Single select value')).toHaveValue('Custom option'); - }); - - it('falls back to the raw value for a locked single-select value not present in options', () => { - render( - - ); - expect(screen.getByPlaceholderText('Single select value')).toHaveValue('stale-option'); - }); - it('calls onGenerateWithAi with the entered prompt when Generate is clicked', async () => { const user = userEvent.setup(); const handleGenerate = vi.fn(); @@ -575,12 +591,6 @@ describe('QuickFormField', () => { expect(handleGenerate).toHaveBeenCalledWith('a random number'); }); - it('shows an empty display instead of "False" for an unset boolean value when locked', () => { - const { container } = render(); - expect(screen.getByPlaceholderText('Boolean value')).toHaveValue(''); - expect(container).not.toHaveTextContent('False'); - }); - it('disables the value mode trigger when onModeChange is not provided', () => { render(); expect(screen.getByRole('button', { name: 'Choose value mode' })).toBeDisabled(); @@ -591,18 +601,14 @@ describe('QuickFormField', () => { expect(screen.getByRole('button', { name: 'Choose value mode' })).not.toBeDisabled(); }); - it('reflects the current field type in the AI-assist output hint', async () => { + it.each([ + ['date', 'literal', 'Output: Date value'], + ['single-select', 'expression', 'Output: Single select expression'], + ] as const)('describes a %s field in %s mode in the AI-assist output hint', async (fieldType, mode, hint) => { const user = userEvent.setup(); - render(); + render(); await user.click(screen.getByRole('button', { name: 'AI assist' })); - expect(screen.getByText('Output: Date expression')).toBeInTheDocument(); - }); - - it('shows a value (not expression) output hint for types that do not support expressions', async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getByRole('button', { name: 'AI assist' })); - expect(screen.getByText('Output: Single select value')).toBeInTheDocument(); + expect(screen.getByText(hint)).toBeInTheDocument(); }); it('reflects the field type and mode in the default label', () => { @@ -623,7 +629,7 @@ describe('QuickFormField', () => { expect(screen.getByRole('button', { name: 'Insert variable' })).toBeDisabled(); }); - it('disables Insert variable while the field is locked', () => { + it('keeps Insert variable available while the field is locked', () => { render( { variables={[{ label: 'Item ID', value: 'item.id' }]} /> ); - expect(screen.getByRole('button', { name: 'Insert variable' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Insert variable' })).not.toBeDisabled(); }); it('appends the selected variable to the current value when Insert variable is used', async () => { @@ -720,13 +726,13 @@ describe('QuickFormField', () => { ); }); - it('grows the multi-select box to hold wrapped chips, and only while unlocked', () => { + it('grows the multi-select box to hold wrapped chips, and only in literal mode', () => { const { container, rerender } = render( - + ); expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'grow'); - rerender(); + rerender(); expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'row'); }); @@ -813,6 +819,7 @@ describe('QuickFormField', () => { { await user.click(screen.getByRole('button', { name: 'AI assist' })); expect(screen.getByText('Ausgabe: Datum-Ausdruck')).toBeInTheDocument(); - await user.type(screen.getByRole('textbox'), 'next week'); + await user.type(screen.getByLabelText('Describe what you want'), 'next week'); await user.click(screen.getByRole('button', { name: 'Generate' })); expect(await screen.findByText('Wird generiert')).toBeInTheDocument(); reject(new Error('nope')); expect(await screen.findByText('Fehlgeschlagen')).toBeInTheDocument(); }); - - it('shows a locked boolean with the strings labels', () => { - render( - - ); - expect(screen.getByDisplayValue('Wahr')).toBeInTheDocument(); - }); }); }); diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx index 53596833a3..a8822563d6 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.tsx @@ -18,12 +18,11 @@ import { cn } from '@/lib'; import { FieldMenu, type FieldMenuItem } from '../field-addons/field-menu'; import { FieldHeader } from './components/field-header'; import { LockToggleButton } from './components/lock-toggle-button'; -import type { QuickFormFieldProps } from './types'; +import type { QuickFormFieldControlProps, QuickFormFieldProps } from './types'; import { DEFAULT_QUICK_FORM_FIELD_STRINGS, FIELD_TYPE_META } from './types'; import { DEFAULT_SELECT_OPTIONS, formatDateValue, - getLockedDisplayValue, parseDateValue, parseListValue, toDateOnlyString, @@ -32,9 +31,9 @@ import { /** * A Quick Form is assembled by end users, like a form builder. QuickFormField is * the control for an individual field within it, and carries its own - * configuration controls: a lock that makes it read-only, a data-type switch - * that swaps the control, a Required toggle, and a value-mode switch across - * `literal`, `expression`, `variable` and `prompt`. + * configuration controls: a lock that marks it read-only for the person filling + * in the form, a data-type switch that swaps the control, a Required toggle, and + * a value-mode switch across `literal`, `expression`, `variable` and `prompt`. * * Only use it within a quick form builder. For standard forms, compose the * form-field anatomy (`FormField`, `FormFieldLabel`, `InputGroup`, …) or @@ -42,6 +41,8 @@ import { * * - Literal and expression have built-in controls; `renderModeControl` * supplies the control for any mode, and `variable` / `prompt` need it. + * - `locked` is configuration the author sets, so the value stays editable; + * `disabled` is what stops the author editing the field. * - `fieldTypes` chooses which types the picker offers. * - Insert variable renders `variables` as supplied and splices at the caret; * `onInsertVariable` takes over what inserting means. @@ -60,6 +61,7 @@ export function QuickFormField({ locked = true, onLockedChange, showLock = true, + disabled = false, leadingAddon, trailingAddon, more, @@ -107,37 +109,31 @@ export function QuickFormField({ const fieldId = id ?? generatedId; const validationId = errorId ?? `${fieldId}-error`; const typeMeta = FIELD_TYPE_META[fieldType]; - // Only `expression` is coerced: a select cannot hold JS, but it can still be - // bound to a variable or filled by a prompt. - const effectiveMode = mode === 'expression' && !typeMeta.supportsExpression ? 'literal' : mode; - const editableOnValueChange = locked ? undefined : onValueChange; + const editableOnValueChange = disabled ? undefined : onValueChange; const typeLabel = strings.typeLabels[fieldType] ?? typeMeta.label; const fieldLabel = - effectiveMode === 'expression' + mode === 'expression' ? strings.expressionFieldLabel(typeLabel) : strings.valueFieldLabel(typeLabel); // The consumer's wording wins on the control; the computed name still labels // the field for assistive tech. const valuePlaceholder = placeholder ?? fieldLabel; const menuActions: FieldMenuItem[] = [ - ...(more?.onClear && !locked + ...(more?.onClear ? [{ id: 'clear', label: strings.clearValue, icon: Trash2, onSelect: more.onClear }] : []), ...(more?.onRefresh ? [{ id: 'refresh', label: strings.forceRefresh, icon: RefreshCw, onSelect: more.onRefresh }] : []), ]; - // Modes and actions share one menu. A type with no expression mode, or a field with no mode - // handler, offers only its actions; with neither, an expression-capable type keeps a disabled - // mode trigger so the field still shows how its value is expressed. - const offersModes = typeMeta.supportsExpression && !!onModeChange; - const showsMenu = typeMeta.supportsExpression || menuActions.length > 0; - const builtInMenu = showsMenu ? ( + // Modes and actions share one menu. A field with no mode handler offers only its actions; with + // neither, it keeps a disabled mode trigger so the field still shows how its value is expressed. + const builtInMenu = ( onModeChange?.(next)} - modesDisabled={!offersModes && menuActions.length > 0} - disabled={!offersModes && menuActions.length === 0} + modesDisabled={!onModeChange && menuActions.length > 0} + disabled={disabled || (!onModeChange && menuActions.length === 0)} actions={menuActions} triggerLabel={strings.valueModeAriaLabel} literalDescription={strings.literalDescriptions[fieldType] ?? typeMeta.fixedDescription} @@ -148,25 +144,25 @@ export function QuickFormField({ fieldActions: strings.moreActionsAriaLabel, }} /> - ) : null; + ); + + const controlProps: QuickFormFieldControlProps = { + id: fieldId, + value, + onValueChange: editableOnValueChange, + onBlur: onValueBlur, + readOnly: !editableOnValueChange, + disabled, + placeholder: valuePlaceholder, + fieldType, + 'aria-invalid': error ? true : undefined, + 'aria-describedby': error ? validationId : undefined, + 'aria-errormessage': error ? validationId : undefined, + 'data-slot': 'input-group-control', + }; - // Resolved before the tree so returning nothing falls back to the built-in - // control. Offered the raw mode, not `effectiveMode`: the coercion protects - // the built-in rendering and must not decide for a consumer with its own editor. - const modeControl = - renderModeControl?.(mode, { - id: fieldId, - value, - onValueChange: editableOnValueChange, - onBlur: onValueBlur, - readOnly: !editableOnValueChange, - placeholder: valuePlaceholder, - fieldType, - 'aria-invalid': error ? true : undefined, - 'aria-describedby': error ? validationId : undefined, - 'aria-errormessage': error ? validationId : undefined, - 'data-slot': 'input-group-control', - }) ?? null; + // Resolved before the tree so returning nothing falls back to the built-in control. + const modeControl = renderModeControl?.(mode, controlProps) ?? null; // Switching mode is a prelude to writing the value, so focus the control. // Skipped on mount so rendering a form cannot steal focus. @@ -186,7 +182,7 @@ export function QuickFormField({ } if (e.key === 'Escape') { e.preventDefault(); - onValueChange?.(committedRef.current); + editableOnValueChange?.(committedRef.current); e.currentTarget.blur(); } }; @@ -214,10 +210,157 @@ export function QuickFormField({ }); }; - // Locked fields are read-only, not disabled — the raw control (switch, date - // picker, select) has nothing left to do once editing is blocked, so it's - // replaced with plain, selectable text showing the same value. - const lockedDisplayValue = getLockedDisplayValue(fieldType, value, options, strings); + // Shared by the expression input and the literal text input, which both + // track the caret for Insert variable. + const textInputProps = { + id: fieldId, + ref: valueInputRef, + readOnly: !editableOnValueChange, + disabled, + value, + onChange: (e: React.ChangeEvent) => editableOnValueChange?.(e.target.value), + onFocus: (e: React.FocusEvent) => { + committedRef.current = value; + caretRef.current = { + start: e.currentTarget.selectionStart ?? value.length, + end: e.currentTarget.selectionEnd ?? value.length, + }; + }, + onSelect: (e: React.SyntheticEvent) => { + const el = e.currentTarget; + caretRef.current = { + start: el.selectionStart ?? 0, + end: el.selectionEnd ?? el.selectionStart ?? 0, + }; + }, + onKeyDown: handleValueKeyDown, + onBlur: (e: React.FocusEvent) => { + // Clicking the Insert trigger blurs this input, so this is the last + // moment the caret is knowable. + caretRef.current = { + start: e.currentTarget.selectionStart ?? value.length, + end: e.currentTarget.selectionEnd ?? value.length, + }; + onValueBlur?.(); + }, + placeholder: valuePlaceholder, + }; + + const literalControl = + fieldType === 'boolean' ? ( +
+ editableOnValueChange?.(String(checked))} + onBlur={onValueBlur} + disabled={!editableOnValueChange} + /> +
+ ) : fieldType === 'date' ? ( + { + setDatePopoverOpen(open); + if (!open) onValueBlur?.(); + }} + > + + + + + editableOnValueChange?.(date ? toDateOnlyString(date) : '')} + initialFocus + /> + + + ) : fieldType === 'datetime' ? ( + // A timestamp needs the time of day, so it uses DateTimePicker rather + // than the date branch's Calendar popover. Stored as an ISO string. + editableOnValueChange?.(date ? date.toISOString() : '')} + onOpenChange={(open) => { + if (!open) onValueBlur?.(); + }} + disabled={!editableOnValueChange} + placeholder={valuePlaceholder} + /> + ) : fieldType === 'file' ? ( + editableOnValueChange?.(files.map((f) => f.name).join(', '))} + disabled={!editableOnValueChange} + onBlur={onValueBlur} + aria-invalid={controlProps['aria-invalid']} + aria-describedby={controlProps['aria-describedby']} + aria-errormessage={controlProps['aria-errormessage']} + /> + ) : fieldType === 'single-select' ? ( + + ) : fieldType === 'multi-select' ? ( + editableOnValueChange?.(JSON.stringify(selected))} + placeholder={strings.selectOptions} + disabled={!editableOnValueChange} + onBlur={onValueBlur} + /> + ) : ( + + ); return (
- {typeMeta.supportsExpression ? ( - - {(showLock || leadingAddon !== undefined) && leadingAddon !== null && ( - - {leadingAddon !== undefined - ? leadingAddon - : showLock && ( - - )} - - )} - - {modeControl ?? - (effectiveMode === 'expression' ? ( - renderExpressionEditor ? ( - renderExpressionEditor({ - id: fieldId, - value, - onValueChange: editableOnValueChange, - onBlur: onValueBlur, - readOnly: !editableOnValueChange, - placeholder: valuePlaceholder, - fieldType, - 'aria-invalid': error ? true : undefined, - 'aria-describedby': error ? validationId : undefined, - 'aria-errormessage': error ? validationId : undefined, - 'data-slot': 'input-group-control', - }) - ) : ( - editableOnValueChange?.(e.target.value)} - onFocus={(e) => { - committedRef.current = value; - caretRef.current = { - start: e.currentTarget.selectionStart ?? value.length, - end: e.currentTarget.selectionEnd ?? value.length, - }; - }} - onSelect={(e) => { - const el = e.currentTarget; - caretRef.current = { - start: el.selectionStart ?? 0, - end: el.selectionEnd ?? el.selectionStart ?? 0, - }; - }} - onKeyDown={handleValueKeyDown} - onBlur={(e) => { - caretRef.current = { - start: e.currentTarget.selectionStart ?? value.length, - end: e.currentTarget.selectionEnd ?? value.length, - }; - onValueBlur?.(); - }} - placeholder={valuePlaceholder} - className="font-mono" - /> - ) - ) : locked ? ( - - ) : fieldType === 'boolean' ? ( -
- onValueChange?.(String(checked))} - onBlur={onValueBlur} - disabled={!onValueChange} - /> -
- ) : fieldType === 'date' ? ( - { - setDatePopoverOpen(open); - if (!open) onValueBlur?.(); - }} - > - - - - - onValueChange?.(date ? toDateOnlyString(date) : '')} - initialFocus + + {(showLock || leadingAddon !== undefined) && leadingAddon !== null && ( + + {leadingAddon !== undefined + ? leadingAddon + : showLock && ( + - - - ) : fieldType === 'datetime' ? ( - // A timestamp needs the time of day, so it uses DateTimePicker rather - // than the date branch's Calendar popover. Stored as an ISO string. - onValueChange?.(date ? date.toISOString() : '')} - onOpenChange={(open) => { - if (!open) onValueBlur?.(); - }} - disabled={!onValueChange} - placeholder={valuePlaceholder} - /> - ) : fieldType === 'file' ? ( - onValueChange?.(files.map((f) => f.name).join(', '))} - disabled={!onValueChange} - onBlur={onValueBlur} - aria-invalid={error ? true : undefined} - aria-describedby={error ? validationId : undefined} - aria-errormessage={error ? validationId : undefined} - /> - ) : ( - onValueChange?.(e.target.value)} - onFocus={(e) => { - committedRef.current = value; - caretRef.current = { - start: e.currentTarget.selectionStart ?? value.length, - end: e.currentTarget.selectionEnd ?? value.length, - }; - }} - onSelect={(e) => { - const el = e.currentTarget; - caretRef.current = { - start: el.selectionStart ?? 0, - end: el.selectionEnd ?? el.selectionStart ?? 0, - }; - }} - onKeyDown={handleValueKeyDown} - onBlur={(e) => { - // Clicking the Insert trigger blurs this input, so this is the - // last moment the caret is knowable. - caretRef.current = { - start: e.currentTarget.selectionStart ?? value.length, - end: e.currentTarget.selectionEnd ?? value.length, - }; - onValueBlur?.(); - }} - placeholder={valuePlaceholder} - /> - ))} + )} + + )} - {trailingAddon !== null && (trailingAddon !== undefined || builtInMenu) && ( - - {trailingAddon !== undefined ? trailingAddon : builtInMenu} - - )} -
- ) : ( - - {(showLock || leadingAddon !== undefined) && leadingAddon !== null && ( - - {leadingAddon !== undefined - ? leadingAddon - : showLock && ( - - )} - - )} + {modeControl ?? + (mode === 'expression' ? ( + renderExpressionEditor ? ( + renderExpressionEditor(controlProps) + ) : ( + + ) + ) : ( + literalControl + ))} - {modeControl ?? - (locked ? ( - - ) : fieldType === 'single-select' ? ( - - ) : fieldType === 'multi-select' ? ( - onValueChange?.(JSON.stringify(selected))} - placeholder={strings.selectOptions} - disabled={!onValueChange} - onBlur={onValueBlur} - /> - ) : null)} - {/* A select can still be bound to a variable, so the consumer's - trailing control renders here too or it loses its mode switch. */} - {trailingAddon !== null && (trailingAddon !== undefined || builtInMenu) && ( - - {trailingAddon !== undefined ? trailingAddon : builtInMenu} - - )} - - )} + {trailingAddon !== null && ( + + {trailingAddon !== undefined ? trailingAddon : builtInMenu} + + )} + {belowValue}
diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/types.ts b/packages/apollo-wind/src/components/ui/quick-form-field/types.ts index c9616a4f8e..6928824c28 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/types.ts +++ b/packages/apollo-wind/src/components/ui/quick-form-field/types.ts @@ -37,6 +37,10 @@ export type QuickFieldType = interface FieldTypeMeta { label: string; icon: LucideIcon; + /** + * @deprecated Will expand mode support in future update. Currently unused. + * Removed in the next major release. + */ supportsExpression: boolean; fixedLabel: string; fixedDescription: string; @@ -88,14 +92,14 @@ export const FIELD_TYPE_META: Record = { 'single-select': { label: 'Single select', icon: List, - supportsExpression: false, + supportsExpression: true, fixedLabel: 'Fixed value', fixedDescription: 'Choose one option', }, 'multi-select': { label: 'Multi select', icon: ListChecks, - supportsExpression: false, + supportsExpression: true, fixedLabel: 'Fixed value', fixedDescription: 'Choose one or more options', }, @@ -156,8 +160,9 @@ export interface QuickFormFieldStrings { valueFieldLabel: (typeLabel: string) => string; /** The computed field name in expression mode, from the (localized) type label. */ expressionFieldLabel: (typeLabel: string) => string; - /** A locked boolean's displayed text. */ + /** @deprecated No longer rendered: a locked boolean keeps its Switch. Removed in the next major release. */ trueLabel: string; + /** @deprecated No longer rendered: a locked boolean keeps its Switch. Removed in the next major release. */ falseLabel: string; requiredTooltip: string; requiredAriaLabel: string; @@ -268,33 +273,60 @@ export interface VariableInsertContext { caret: { start: number; end: number } | null; } +/** What a consumer-rendered value control receives from `renderModeControl` or `renderExpressionEditor`. */ +export interface QuickFormFieldControlProps { + id: string; + value: string; + /** Omitted while the field is disabled or has no `onValueChange`. */ + onValueChange?: (value: string) => void; + onBlur?: () => void; + readOnly: boolean; + disabled: boolean; + placeholder: string; + fieldType: QuickFieldType; + 'aria-invalid'?: AriaAttributes['aria-invalid']; + 'aria-describedby'?: string; + 'aria-errormessage'?: string; + 'data-slot'?: string; +} + export interface QuickFormFieldProps { /** Current field value. Encoding depends on fieldType (e.g. multi-select is a JSON array string). */ value?: string; - /** Called when the user edits the value (only fires while unlocked). */ + /** Called when the user edits the value. Never fires while `disabled`. */ onValueChange?: (value: string) => void; /** Called when the active value control loses focus. */ onValueBlur?: () => void; - /** Whether the field is read-only. Defaults to true. */ + /** + * Whether the value is read-only for the person filling in the form. It is + * configuration the author sets, so the value stays editable here; use + * `disabled` to stop editing. Defaults to true. + */ locked?: boolean; /** Called when the user toggles the lock. */ onLockedChange?: (locked: boolean) => void; /** Whether the built-in lock control renders. Defaults to true. */ showLock?: boolean; + /** + * Disables every built-in control: the value control, the lock, the mode and + * actions menu, and the header's type picker, Required switch, Insert + * variable and AI assist. `headerActions` are the consumer's to disable. + */ + disabled?: boolean; /** * Replaces the leading lock toggle with a semantic prefix such as `=`. * Pass `null` to suppress the built-in toggle; omitting the prop preserves it. */ leadingAddon?: ReactNode; /** - * Replaces the trailing fixed/expression menu with a consumer-provided action - * for expression-capable field types. Pass `null` to suppress the built-in + * Replaces the trailing fixed/expression menu with a consumer-provided + * action. Pass `null` to suppress the built-in * menu; omitting the prop preserves it. */ trailingAddon?: ReactNode; /** Adds a field-level overflow menu beside the value control. */ more?: QuickFormFieldMoreActions; - /** How the value is expressed. Defaults to 'literal'; `expression` falls back to `literal` for types that cannot hold one. */ + /** How the value is expressed. Defaults to 'literal'. */ mode?: QuickFormFieldMode; /** Called when the user switches modes. */ onModeChange?: (mode: QuickFormFieldMode) => void; @@ -306,19 +338,7 @@ export interface QuickFormFieldProps { * existing callers; `renderModeControl` wins when both are supplied and it * returns something for `expression`. */ - renderExpressionEditor?: (props: { - id: string; - value: string; - onValueChange?: (value: string) => void; - onBlur?: () => void; - readOnly: boolean; - placeholder: string; - fieldType: QuickFieldType; - 'aria-invalid'?: AriaAttributes['aria-invalid']; - 'aria-describedby'?: string; - 'aria-errormessage'?: string; - 'data-slot'?: string; - }) => ReactNode; + renderExpressionEditor?: (props: QuickFormFieldControlProps) => ReactNode; /** * Renders the value control for a given mode, replacing the built-in one. * `variable` and `prompt` have no built-in editor, and a surface that authors @@ -328,22 +348,7 @@ export interface QuickFormFieldProps { * for the field's type, so an unhandled mode degrades to the literal editor * rather than rendering an empty row. */ - renderModeControl?: ( - mode: QuickFormFieldMode, - props: { - id: string; - value: string; - onValueChange?: (value: string) => void; - onBlur?: () => void; - readOnly: boolean; - placeholder: string; - fieldType: QuickFieldType; - 'aria-invalid'?: AriaAttributes['aria-invalid']; - 'aria-describedby'?: string; - 'aria-errormessage'?: string; - 'data-slot'?: string; - } - ) => ReactNode; + renderModeControl?: (mode: QuickFormFieldMode, props: QuickFormFieldControlProps) => ReactNode; /** * Takes over what choosing a variable does to the value. The component owns * the picker and hands back the chosen entry exactly as supplied in diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts index d95ed8be71..a41a6c64e7 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts +++ b/packages/apollo-wind/src/components/ui/quick-form-field/utils.ts @@ -1,4 +1,4 @@ -import type { QuickFieldType, QuickFormFieldOption } from './types'; +import type { QuickFormFieldOption } from './types'; export const DEFAULT_SELECT_OPTIONS: QuickFormFieldOption[] = [ { label: 'Option 1', value: 'option-1' }, @@ -59,59 +59,3 @@ export function formatDateValue(value: string): string { ? date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : value; } - -// Separate from formatDateValue so the date-only output stays unchanged. -/** Formats a datetime field's value, falling back to the raw value when unparseable. */ -export function formatDateTimeValue(value: string): string { - const date = parseDateValue(value); - return date - ? date.toLocaleString(undefined, { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }) - : value; -} - -/** - * Computes the plain-text shown in place of the real control once a field is locked. - * Boolean/single-select/multi-select resolve their stored value to a display label; - * everything else (including an invalid date, so the component never throws on - * external input) falls back to the raw value. - */ -export function getLockedDisplayValue( - fieldType: QuickFieldType, - value: string, - options: QuickFormFieldOption[], - booleanLabels: { trueLabel: string; falseLabel: string } = { - trueLabel: 'True', - falseLabel: 'False', - } -): string { - switch (fieldType) { - case 'boolean': - if (value === 'true') return booleanLabels.trueLabel; - if (value === 'false') return booleanLabels.falseLabel; - return ''; - case 'date': - return value ? formatDateValue(value) : ''; - case 'datetime': - return value ? formatDateTimeValue(value) : ''; - case 'single-select': - return options.find((option) => option.value === value)?.label ?? value; - case 'multi-select': { - const parsed = parseListValue(value); - // A malformed value (not JSON, or an array with no string entries) also - // parses to an empty list -- fall back to the raw value so it's still - // visible, rather than rendering as if the field were genuinely empty. - if (parsed.length === 0 && value && value !== '[]') { - return value; - } - return parsed.map((v) => options.find((option) => option.value === v)?.label ?? v).join(', '); - } - default: - return value; - } -} diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts index a4ae265b5e..4f7b25512b 100644 --- a/packages/apollo-wind/src/index.ts +++ b/packages/apollo-wind/src/index.ts @@ -647,6 +647,7 @@ export type { PromptValueControlProps } from './components/ui/prompt-value-contr export { PromptValueControl } from './components/ui/prompt-value-control'; export type { QuickFieldType, + QuickFormFieldControlProps, QuickFormFieldMode, QuickFormFieldMoreActions, QuickFormFieldOption,