From 55b9f2e69ec6579ce36c25ae73e37f9e185a5305 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Wed, 21 Oct 2026 11:36:44 -0700 Subject: [PATCH] feat(apollo-wind): built-in variable and prompt modes in QuickFormField [MST-16146] QuickFormField now renders every value mode itself. Variable mode is a picker over `variables` (VariableValueControl), and prompt mode is a PromptValueControl in the agent-tinted InputGroup, which grows with it. `renderModeControl` still replaces the control for any mode. - `valueModes` lists the modes the built-in menu offers, in order. It defaults to literal and expression, as before. - `confirmModeChange(from, to)` asks before a switch the consumer knows is lossy, through ValueModeSwitchDialog. It returns true, false, or the dialog's wording. - A variable-mode pick goes to `onInsertVariable` when supplied, with the consumer's own entry, so a consumer can adopt its type. The header's Insert action is hidden while the picker is the value control. - VariableValueControl's `onChange` also gets the picked entry when its own picker made the pick. A host picker reports only the reference, so its pick has none. - Multi-select keeps to one row with `overflow="collapse"`. - New strings: `valueModes` (ValueModeStrings), `multiSelect` (MultiSelectStrings), and the multi-select's search, empty and Clear all text. - Clear value is styled as destructive. - Focus on a mode switch waits a frame: a popover trigger remounts once its anchor registers, which dropped focus given straight away. - The menu and the confirm dialog leave focus on the new control as they close, instead of returning it to the trigger after their exit animation. FieldMenu and ValueModeSwitchDialog gain `onCloseAutoFocus`. Co-Authored-By: Claude Opus 5.5 --- .../apollo-wind/docs/AI_CONSUMER_GUIDE.md | 2 +- .../ui/field-addons/field-menu.test.tsx | 12 + .../components/ui/field-addons/field-menu.tsx | 8 +- .../value-mode-switch-dialog.test.tsx | 23 ++ .../field-addons/value-mode-switch-dialog.tsx | 8 +- .../components/field-header.tsx | 63 +--- .../components/ui/quick-form-field/index.ts | 1 + .../quick-form-field.divergence.test.tsx | 13 +- .../quick-form-field.stories.tsx | 99 +---- .../quick-form-field.test.tsx | 353 +++++++++++++++++- .../ui/quick-form-field/quick-form-field.tsx | 189 ++++++++-- .../components/ui/quick-form-field/types.ts | 68 +++- .../components/ui/quick-form-field/utils.ts | 27 ++ .../ui/variable-value-control.test.tsx | 8 +- .../components/ui/variable-value-control.tsx | 16 +- packages/apollo-wind/src/index.ts | 1 + 16 files changed, 707 insertions(+), 184 deletions(-) diff --git a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md index b5dfdc3635..f5a2b9f43a 100644 --- a/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md +++ b/packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md @@ -142,7 +142,7 @@ Same props as Row. | `AiAssistAction` | Header AI prompt | `onGenerate`, `hint`, `strings`. `renderContent({ close, disabled })` renders the host's own panel instead of the prompt form | | `VariableValueControl` | Variable-bound control | `value`, `onChange`, `variables`, `strings`. `renderPicker` renders the host's own picker; `describeReference(reference)` gives the chip a `{ label, icon? }` | | `PromptValueControl` | Prompt for an agent | `value`, `onChange`, `strings`. Render in `InputGroup` with `variant="agent"` `layout="grow"` | -| `QuickFormField` | Quick Form builder field | Only where end users configure fields in a HITL Quick Form. `fieldTypes`, `renderModeControl`, `variables`, `strings` | +| `QuickFormField` | Quick Form builder field | Only where end users configure fields in a HITL Quick Form. `fieldTypes`, `valueModes`, `confirmModeChange`, `renderModeControl`, `variables`, `strings` | | `Label` | Bare label primitive | `htmlFor`. For a field use `FormFieldLabel` | | **Data Display** | | | | `Card` | Container card | Children: `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | diff --git a/packages/apollo-wind/src/components/ui/field-addons/field-menu.test.tsx b/packages/apollo-wind/src/components/ui/field-addons/field-menu.test.tsx index 5d7d90a470..05be39e598 100644 --- a/packages/apollo-wind/src/components/ui/field-addons/field-menu.test.tsx +++ b/packages/apollo-wind/src/components/ui/field-addons/field-menu.test.tsx @@ -19,6 +19,18 @@ describe('FieldMenu', () => { expect(screen.getByRole('button', { name: 'Expression' })).toBeInTheDocument(); }); + it('lets a field keep focus off the trigger as the menu closes', async () => { + const user = userEvent.setup(); + const onCloseAutoFocus = vi.fn((event: Event) => event.preventDefault()); + render(); + const trigger = screen.getByRole('button', { name: 'Fixed value' }); + await user.click(trigger); + const focus = vi.spyOn(trigger, 'focus'); + await user.click(screen.getByRole('menuitemradio', { name: /Expression/ })); + expect(onCloseAutoFocus).toHaveBeenCalledOnce(); + expect(focus).not.toHaveBeenCalled(); + }); + it('names the trigger with triggerLabel while it offers modes', () => { const { rerender } = render( diff --git a/packages/apollo-wind/src/components/ui/field-addons/field-menu.tsx b/packages/apollo-wind/src/components/ui/field-addons/field-menu.tsx index 951169aa30..92d0d8007b 100644 --- a/packages/apollo-wind/src/components/ui/field-addons/field-menu.tsx +++ b/packages/apollo-wind/src/components/ui/field-addons/field-menu.tsx @@ -55,6 +55,11 @@ interface FieldMenuBaseProps { triggerLabel?: string; /** Overrides for any subset of the English strings. */ strings?: Partial; + /** + * Called as the menu closes, before it returns focus to its trigger. Call `preventDefault` to keep + * focus elsewhere, such as on the field's control after a mode is chosen. + */ + onCloseAutoFocus?: (event: Event) => void; } /** @@ -142,6 +147,7 @@ export function FieldMenu({ literalDescription, triggerLabel: triggerLabelOverride, strings, + onCloseAutoFocus, }: FieldMenuProps) { const text = { ...DEFAULT_VALUE_MODE_STRINGS, ...strings }; const checkedMode = checked ?? mode; @@ -183,7 +189,7 @@ export function FieldMenu({ - + {!modesDisabled && resolvedModes.map((item) => ( { expect(onConfirm).toHaveBeenCalled(); }); + it('can keep focus off where it opened from as it closes', async () => { + const user = userEvent.setup(); + const onCloseAutoFocus = vi.fn((event: Event) => event.preventDefault()); + const { rerender } = render( + + ); + rerender( + + ); + await user.keyboard('{Tab}'); + expect(onCloseAutoFocus).toHaveBeenCalledOnce(); + }); + it("prefers the codec's own wording, then the given strings", () => { render( ; + /** + * Called as the dialog closes, before it returns focus to where it opened from. Call + * `preventDefault` to keep focus elsewhere, such as on the control of the mode just switched to. + */ + onCloseAutoFocus?: (event: Event) => void; } /** Confirms a mode switch that would lose the field's current value. */ @@ -33,11 +38,12 @@ export function ValueModeSwitchDialog({ description, confirmLabel, strings, + onCloseAutoFocus, }: ValueModeSwitchDialogProps) { const text = { ...DEFAULT_VALUE_MODE_STRINGS, ...strings }; return ( !next && onCancel()}> - + {title ?? text.lossyTitle} {description ?? text.lossyDescription} 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 d7a3160d88..7eacc9950c 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 @@ -1,6 +1,5 @@ import { Asterisk, ChevronDown } from 'lucide-react'; import type { ReactNode } from 'react'; -import { useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -12,13 +11,13 @@ import { FormFieldHeader } from '@/components/ui/form-field'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import type { VariablePickerItem } from '@/components/ui/variable-picker'; import { cn } from '@/lib'; import { AiAssistAction } from '../../field-actions/ai-assist-action'; import { COLLAPSED_HIDDEN, COLLAPSED_ONLY, FIELD_ACTION_CLASS } from '../../field-actions/collapse'; import { InsertVariableAction } from '../../field-actions/insert-variable-action'; import type { QuickFieldType, QuickFormFieldOption, QuickFormFieldStrings } from '../types'; import { FIELD_TYPE_META, FIELD_TYPE_ORDER } from '../types'; +import type { VariablePickerSource } from '../utils'; export function FieldHeader({ label, @@ -55,33 +54,14 @@ export function FieldHeader({ isExpression: boolean; showFieldActions: boolean; onValueChange?: (value: string) => void; - variables: QuickFormFieldOption[]; + variables: VariablePickerSource; onGenerateWithAi?: (prompt: string) => void; - /** See QuickFormFieldProps.onInsertVariable. */ + /** See QuickFormFieldProps.onInsertVariable. Omitted, the Insert action does not render. */ onInsertVariable?: (variable: QuickFormFieldOption) => void; /** See QuickFormFieldStrings. */ strings: QuickFormFieldStrings; headerActions?: ReactNode; }) { - // Built together so the entry handed to `onInsertVariable` is the one the - // consumer supplied, not a reconstruction. Memoized because the picker items - // were being rebuilt — recursively, with a fresh object per variable and per - // child — on every render of every row, while the picker was closed. - const [pickerItems, optionsBySelectionId] = useMemo(() => { - // Ids come from tree position: neither labels nor values are required to be unique. - const byId = new Map(); - const toItem = (v: QuickFormFieldOption, id: string): VariablePickerItem => { - byId.set(id, v); - return { - id, - label: v.label, - ...(v.value ? { value: v.value } : {}), - ...(v.type ? { type: v.type } : {}), - ...(v.children ? { children: v.children.map((c, i) => toItem(c, `${id}/${i}`)) } : {}), - }; - }; - return [variables.map((v, i) => toItem(v, String(i))), byId] as const; - }, [variables]); const typeMeta = FIELD_TYPE_META[fieldType]; const typeLabel = strings.typeLabels[fieldType] ?? typeMeta.label; const collapsedTextClass = cn(COLLAPSED_HIDDEN, compact && '!hidden'); @@ -228,27 +208,22 @@ export function FieldHeader({ }} /> )} - - onInsertVariable( - optionsBySelectionId.get(item.id) ?? { label: item.label, value } - )) - } - /> + {onInsertVariable && ( + onInsertVariable(variables.resolve(item))} + /> + )} )} 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 3a24a3707b..f5d84b1cb7 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 @@ -3,6 +3,7 @@ export type { QuickFieldType, QuickFormFieldControlProps, QuickFormFieldMode, + QuickFormFieldModeSwitchConfirm, QuickFormFieldMoreActions, QuickFormFieldOption, QuickFormFieldProps, 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 28ddadf8f6..5fef53cde4 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 @@ -467,10 +467,17 @@ describe('QuickFormField — divergences from apollo-wind', () => { expect(screen.getByPlaceholderText('String value')).toBeInTheDocument(); }); - // An unhandled mode must degrade to the literal editor, not an empty row. + // Every mode has a built-in control, so an unhandled one is never an empty row. it('falls back to the built-in control when the consumer renders nothing', () => { - render(); - expect(screen.getByPlaceholderText('String value')).toBeInTheDocument(); + render( + null} + /> + ); + expect(screen.getByText('Select a variable')).toBeInTheDocument(); }); }); }); diff --git a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.stories.tsx b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.stories.tsx index 51d971fc9c..078d2ac8b8 100644 --- a/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.stories.tsx +++ b/packages/apollo-wind/src/components/ui/quick-form-field/quick-form-field.stories.tsx @@ -1,16 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Braces, Code2, MessageSquareText, Trash2, Type } from 'lucide-react'; +import { Trash2 } from 'lucide-react'; import { useId, useState } from 'react'; import { Button } from '../button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../dropdown-menu'; -import { InputGroupButton, InputGroupInput } from '../input-group'; import { Label, RequiredIndicator } from '../label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select'; import { QuickFormField } from './quick-form-field'; import type { QuickFieldType, QuickFormFieldMode, QuickFormFieldStrings } from './types'; @@ -32,9 +24,10 @@ the form-field anatomy (\`FormField\`, \`FormFieldLabel\`, \`InputGroup\`). See - Left lock icon toggles Editable / Read-only for the person filling in the form. The author can still edit a read-only value; \`disabled\` is what disables the controls. -- Right value-mode icon switches between Fixed value and Expression, - updating the value styling. \`renderModeControl\` supplies the control for any mode, and a - consumer's \`trailingAddon\` can offer all four. See **Variable and prompt modes**. +- Right value-mode icon switches between the modes \`valueModes\` lists: Fixed value and + Expression by default, plus Variable (a picker over \`variables\`) and Prompt (an agent + textarea). \`confirmModeChange\` asks before a lossy switch, and \`renderModeControl\` + replaces the control for any mode. See **Variable and prompt modes**. - Field type dropdown swaps the control itself: String, Text area, Integer, Decimal number, Date, Date and time, Boolean, Single select, Multi select, Array, and File each render their own matching input. \`fieldTypes\` chooses which @@ -439,45 +432,11 @@ export const Disabled: Story = { }, }; -const MODE_ITEMS: { mode: QuickFormFieldMode; label: string; icon: typeof Type }[] = [ - { mode: 'literal', label: 'Fixed value', icon: Type }, - { mode: 'expression', label: 'Expression', icon: Code2 }, - { mode: 'variable', label: 'Variable', icon: Braces }, - { mode: 'prompt', label: 'Prompt', icon: MessageSquareText }, -]; - const WORKFLOW_VARIABLES = [ - { label: 'Customer name', value: 'vars.customerName' }, - { label: 'Invoice number', value: 'vars.invoiceNumber' }, + { label: 'Customer name', value: 'vars.customerName', type: 'string' }, + { label: 'Invoice number', value: 'vars.invoiceNumber', type: 'string' }, ]; -function FourModeMenu({ - mode, - onModeChange, -}: { - mode: QuickFormFieldMode; - onModeChange: (mode: QuickFormFieldMode) => void; -}) { - const active = MODE_ITEMS.find((item) => item.mode === mode) ?? MODE_ITEMS[0]; - return ( - - - - - - - - {MODE_ITEMS.map((item) => ( - onModeChange(item.mode)}> - - {item.label} - - ))} - - - ); -} - function ValueModesDemo() { const fieldId = useId(); const [mode, setMode] = useState('variable'); @@ -488,6 +447,11 @@ function ValueModesDemo() { prompt: '', }); const setValue = (value: string) => setValues((current) => ({ ...current, [mode]: value })); + // Each mode keeps its own value, except that leaving prompt mode drops the prompt. + const changeMode = (next: QuickFormFieldMode) => { + if (mode === 'prompt') setValues((current) => ({ ...current, prompt: '' })); + setMode(next); + }; return (
@@ -500,39 +464,10 @@ function ValueModesDemo() { showLock={false} showFieldActions={false} mode={mode} - onModeChange={setMode} - trailingAddon={} - renderModeControl={(activeMode, control) => { - if (activeMode === 'variable') { - return ( - - ); - } - if (activeMode === 'prompt') { - return ( - control.onValueChange?.(event.target.value)} - onBlur={control.onBlur} - readOnly={control.readOnly} - placeholder="Describe the value for the agent to fill in" - /> - ); - } - return null; - }} + onModeChange={changeMode} + valueModes={['literal', 'expression', 'variable', 'prompt']} + confirmModeChange={(from) => from === 'prompt' && values.prompt !== ''} + variables={WORKFLOW_VARIABLES} />
); @@ -545,7 +480,7 @@ export const ValueModes: Story = { docs: { description: { story: - 'A field can be a literal, an expression, a bound variable or an agent prompt. `renderModeControl` supplies the control for any mode and returns nothing to keep the built-in one, so literal and expression still render the defaults here. `trailingAddon` carries the four-mode switch, and it renders for every type, including selects.', + 'A field can be a literal, an expression, a bound variable or an agent prompt. `valueModes` lists the modes the menu offers. Variable mode is a picker over `variables`, and prompt mode a growing textarea in the agent-tinted box. `confirmModeChange` asks before a switch that would lose the value. Here leaving prompt mode clears the prompt, so that switch asks first when the prompt has text. `renderModeControl` still replaces the control for any mode.', }, }, }, 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 6b46356399..b1cc996516 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 @@ -1,8 +1,10 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { QuickFormField } from './quick-form-field'; +import type { QuickFormFieldMode } from './types'; describe('QuickFormField', () => { it('keeps the value editable when locked', () => { @@ -726,14 +728,19 @@ describe('QuickFormField', () => { ); }); - it('grows the multi-select box to hold wrapped chips, and only in literal mode', () => { - const { container, rerender } = render( - + it('keeps the multi-select on one row, counting chips that do not fit', () => { + const { container } = render( + `${count} weitere` } }} + /> ); - expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'grow'); - - rerender(); expect(container.querySelector('[role="group"]')).toHaveAttribute('data-layout', 'row'); + // The count is measured, so every count it could show (1 to 2 of 3 chips) is mounted. + expect(screen.getAllByText(/weitere/)).toHaveLength(2); }); it('has no accessibility violations', async () => { @@ -749,6 +756,294 @@ describe('QuickFormField', () => { expect(results).toHaveNoViolations(); }); + describe('value modes', () => { + const FOUR_MODES = ['literal', 'expression', 'variable', 'prompt'] as const; + const VARIABLES = [ + { label: 'Customer name', value: 'vars.customerName', type: 'string' }, + { label: 'Invoice total', value: 'vars.invoiceTotal', type: 'number' }, + ]; + + it('offers the listed modes, in order', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Choose value mode' })); + const items = screen.getAllByRole('menuitemradio'); + expect(items.map((item) => item.textContent)).toEqual([ + expect.stringContaining('Variable'), + expect.stringContaining('Fixed value'), + ]); + }); + + it('offers no switch with a single mode, only the actions', 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' })); + expect(screen.queryByRole('menuitemradio')).not.toBeInTheDocument(); + }); + + it('marks Clear value as destructive', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Choose value mode' })); + expect(screen.getByRole('menuitem', { name: 'Clear value' })).toHaveClass('text-error'); + }); + + it('renders a variable picker in variable mode, and drops the header Insert', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render( + + ); + expect(screen.queryByRole('button', { name: 'Insert variable' })).not.toBeInTheDocument(); + await user.click(screen.getByText('Select a variable')); + await user.click(await screen.findByText('Invoice total')); + expect(handleValueChange).toHaveBeenCalledWith('vars.invoiceTotal'); + }); + + it('shows the bound reference in variable mode', () => { + render(); + expect(screen.getByText('vars.customerName')).toBeInTheDocument(); + }); + + it("hands a variable-mode pick to onInsertVariable as the consumer's own entry", async () => { + const user = userEvent.setup(); + const handleInsert = vi.fn(); + const handleValueChange = vi.fn(); + render( + + ); + await user.click(screen.getByText('vars.customerName')); + await user.click(await screen.findByText('Invoice total')); + expect(handleInsert).toHaveBeenCalledWith(VARIABLES[1], { + value: 'vars.customerName', + caret: null, + }); + expect(handleValueChange).not.toHaveBeenCalled(); + }); + + it('disables the variable picker while disabled', () => { + render( + + ); + expect(screen.getByText('Select a variable').closest('button')).toBeDisabled(); + }); + + it('keeps a consumer variable control, and the header Insert beside it', () => { + render( + (mode === 'variable' ? typed binding : null)} + /> + ); + expect(screen.getByText('typed binding')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Insert variable' })).toBeInTheDocument(); + }); + + it('renders the prompt in an agent box that grows with it', () => { + const handleValueChange = vi.fn(); + const { container } = render( + + ); + const prompt = screen.getByPlaceholderText('Describe the value'); + expect(prompt.tagName).toBe('TEXTAREA'); + const group = container.querySelector('[role="group"]'); + expect(group).toHaveAttribute('data-layout', 'grow'); + expect(group).toHaveClass('border-gradient-agent-start/60'); + fireEvent.change(prompt, { target: { value: 'The customer on the invoice' } }); + expect(handleValueChange).toHaveBeenCalledWith('The customer on the invoice'); + }); + + it.each([ + ['variable', 'Select a variable'], + ['prompt', 'Describe the value'], + ] as const)('focuses the %s control when switched to it', async (mode, text) => { + const { rerender } = render(); + rerender(); + const control = () => + mode === 'prompt' + ? screen.getByPlaceholderText(text) + : screen.getByText(text).closest('button'); + await waitFor(() => expect(control()).toHaveFocus()); + }); + + // The menu and dialog return focus to where they opened from once they finish closing, which + // in a browser lands after the frame the control takes focus in. jsdom closes them at once, so + // these watch the trigger instead of the timing. + describe('focus as the menu or dialog closes', () => { + it('keeps focus off the menu trigger after a switch', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Choose value mode' }); + await user.click(trigger); + const focus = vi.spyOn(trigger, 'focus'); + await user.click(screen.getByRole('menuitemradio', { name: /Prompt/ })); + expect(focus).not.toHaveBeenCalled(); + }); + + it('returns focus to the menu trigger when the active mode is picked again', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Choose value mode' }); + await user.click(trigger); + const focus = vi.spyOn(trigger, 'focus'); + await user.click(screen.getByRole('menuitemradio', { name: /Fixed value/ })); + expect(focus).toHaveBeenCalled(); + }); + + it('leaves focus on the new control after a confirmed switch', async () => { + const user = userEvent.setup(); + function Switching() { + const [mode, setMode] = useState('literal'); + return ( + true} + /> + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Choose value mode' })); + await user.click(screen.getByRole('menuitemradio', { name: /Prompt/ })); + const confirm = screen.getByRole('button', { name: 'Switch' }); + // Every element focused from here on, other than the button clicked. + const focused: HTMLElement[] = []; + const nativeFocus = HTMLElement.prototype.focus; + const focus = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(function ( + this: HTMLElement, + options?: FocusOptions + ) { + if (this !== confirm) focused.push(this); + nativeFocus.call(this, options); + }); + await user.click(confirm); + await waitFor(() => + expect(screen.getByPlaceholderText('Describe the value')).toHaveFocus() + ); + expect(focused.every((el) => el.tagName === 'TEXTAREA')).toBe(true); + focus.mockRestore(); + }); + }); + + it('does not take focus on mount', async () => { + render(); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect(document.body).toHaveFocus(); + }); + + describe('confirmModeChange', () => { + const openMenuAndPick = async (user: ReturnType, name: RegExp) => { + await user.click(screen.getByRole('button', { name: 'Choose value mode' })); + await user.click(screen.getByRole('menuitemradio', { name })); + }; + + it('switches straight away when it returns false', async () => { + const user = userEvent.setup(); + const handleModeChange = vi.fn(); + render( + false} + /> + ); + await openMenuAndPick(user, /Prompt/); + expect(handleModeChange).toHaveBeenCalledWith('prompt'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('asks first, and switches only once confirmed', async () => { + const user = userEvent.setup(); + const handleModeChange = vi.fn(); + const confirm = vi.fn(() => true); + render( + + ); + await openMenuAndPick(user, /Prompt/); + expect(confirm).toHaveBeenCalledWith('literal', 'prompt'); + expect(screen.getByRole('alertdialog', { name: 'Switch value mode?' })).toBeInTheDocument(); + expect(handleModeChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Switch' })); + expect(handleModeChange).toHaveBeenCalledOnce(); + expect(handleModeChange).toHaveBeenCalledWith('prompt'); + }); + + it('keeps the mode when cancelled', async () => { + const user = userEvent.setup(); + const handleModeChange = vi.fn(); + render( + true} + /> + ); + await openMenuAndPick(user, /Prompt/); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(handleModeChange).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('words the dialog from what it returns, then from strings', async () => { + const user = userEvent.setup(); + render( + ({ title: 'Drop the prompt?', confirmLabel: 'Drop' })} + strings={{ + valueModes: { lossyDescription: 'Der Wert geht verloren.', cancel: 'Abbrechen' }, + }} + /> + ); + await openMenuAndPick(user, /Prompt/); + const dialog = screen.getByRole('alertdialog', { name: 'Drop the prompt?' }); + expect(dialog).toHaveTextContent('Der Wert geht verloren.'); + expect(screen.getByRole('button', { name: 'Drop' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Abbrechen' })).toBeInTheDocument(); + }); + + it('does not ask when the active mode is picked again', async () => { + const user = userEvent.setup(); + const confirm = vi.fn(() => true); + render( + + ); + await openMenuAndPick(user, /Fixed value/); + expect(confirm).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + }); + }); + describe('strings', () => { it('reads the header chrome from strings', () => { render( @@ -791,6 +1086,50 @@ describe('QuickFormField', () => { expect(screen.getByText('Use a literal string value')).toBeInTheDocument(); }); + it('reads the variable and prompt modes from strings.valueModes', async () => { + const user = userEvent.setup(); + const { rerender } = render( + + ); + await user.click(screen.getByRole('button', { name: 'Choose value mode' })); + expect(screen.getByRole('menuitemradio', { name: /Variable binden/ })).toBeInTheDocument(); + expect(screen.getByRole('menuitemradio', { name: /Anweisung/ })).toBeInTheDocument(); + await user.keyboard('{Escape}'); + + rerender( + + ); + expect(screen.getByText('Variable wählen')).toBeInTheDocument(); + }); + + it("reads the multi-select's search, empty and Clear all text from strings", async () => { + const user = userEvent.setup(); + render( + `Alle ${count} entfernen`, + }} + /> + ); + await user.click(screen.getByRole('combobox')); + expect(screen.getByText('Alle 1 entfernen')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText('Suchen...'), 'zzz'); + expect(screen.getByText('Nichts gefunden.')).toBeInTheDocument(); + }); + it("reads the variable picker's search and empty text from strings", async () => { const user = userEvent.setup(); render( 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 a7cbc757e0..2d60ad805d 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 @@ -11,6 +11,7 @@ import { } from '@/components/ui/input-group'; import { MultiSelect } from '@/components/ui/multi-select'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { PromptValueControl } from '@/components/ui/prompt-value-control'; import { Select, SelectContent, @@ -19,14 +20,29 @@ import { SelectValue, } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; +import { VariableValueControl } from '@/components/ui/variable-value-control'; import { cn } from '@/lib'; import { toDateValue } from '@/lib/date-value'; import { FieldMenu, type FieldMenuItem } from '../field-addons/field-menu'; +import { ValueModeSwitchDialog } from '../field-addons/value-mode-switch-dialog'; import { FieldHeader } from './components/field-header'; import { LockToggleButton } from './components/lock-toggle-button'; -import type { QuickFormFieldControlProps, QuickFormFieldProps } from './types'; +import type { + QuickFormFieldControlProps, + QuickFormFieldMode, + QuickFormFieldModeSwitchConfirm, + QuickFormFieldProps, +} from './types'; import { DEFAULT_QUICK_FORM_FIELD_STRINGS, FIELD_TYPE_META } from './types'; -import { DEFAULT_SELECT_OPTIONS, formatDateValue, parseListValue, toDateOnlyString } from './utils'; +import { + DEFAULT_SELECT_OPTIONS, + formatDateValue, + parseListValue, + toDateOnlyString, + toVariablePickerSource, +} from './utils'; + +const DEFAULT_VALUE_MODES: readonly QuickFormFieldMode[] = ['literal', 'expression']; /** * A Quick Form is assembled by end users, like a form builder. QuickFormField is @@ -39,8 +55,10 @@ import { DEFAULT_SELECT_OPTIONS, formatDateValue, parseListValue, toDateOnlyStri * form-field anatomy (`FormField`, `FormFieldLabel`, `InputGroup`, …) or * describe them to `MetadataForm`. * - * - Literal and expression have built-in controls; `renderModeControl` - * supplies the control for any mode, and `variable` / `prompt` need it. + * - Every mode has a built-in control: `variable` is a picker over + * `variables`, `prompt` an agent textarea. `valueModes` chooses which the + * menu offers, and `renderModeControl` replaces the control for any mode. + * - `confirmModeChange` asks before a switch the consumer knows is lossy. * - `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. @@ -68,6 +86,8 @@ export function QuickFormField({ more, mode = 'literal', onModeChange, + valueModes = DEFAULT_VALUE_MODES, + confirmModeChange, renderExpressionEditor, renderModeControl, fieldType = 'string', @@ -105,8 +125,19 @@ export function QuickFormField({ // Null until the author places a caret: a never-focused input reports // selectionStart 0, which would prepend an inserted variable. const caretRef = useRef<{ start: number; end: number } | null>(null); + // The variable trigger or prompt textarea, for focus on a mode switch. + const modeControlRef = useRef(null); + const setModeControl = useCallback((el: HTMLElement | null) => { + modeControlRef.current = el; + }, []); const [datePopoverOpen, setDatePopoverOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false); + const [pendingSwitch, setPendingSwitch] = useState< + QuickFormFieldModeSwitchConfirm & { to: QuickFormFieldMode } + >(); + // Shared by the Insert menu and the variable-mode picker. Memoized because it + // rebuilds the tree recursively, which every row did on every render. + const variablePicker = useMemo(() => toVariablePickerSource(variables), [variables]); const fieldId = id ?? generatedId; const validationId = errorId ?? `${fieldId}-error`; const typeMeta = FIELD_TYPE_META[fieldType]; @@ -121,29 +152,61 @@ export function QuickFormField({ const valuePlaceholder = placeholder ?? fieldLabel; const menuActions: FieldMenuItem[] = [ ...(more?.onClear - ? [{ id: 'clear', label: strings.clearValue, icon: Trash2, onSelect: more.onClear }] + ? [ + { + id: 'clear', + label: strings.clearValue, + icon: Trash2, + destructive: true, + onSelect: more.onClear, + }, + ] : []), ...(more?.onRefresh ? [{ id: 'refresh', label: strings.forceRefresh, icon: RefreshCw, onSelect: more.onRefresh }] : []), ]; - // Modes and actions share one menu. A field with no mode handler offers only its actions; with + const valueModeStrings = { + ...strings.valueModes, + literalTitle: strings.literalLabels[fieldType] ?? typeMeta.fixedLabel, + expressionTitle: strings.expressionLabel, + expressionDescription: strings.expressionDescription, + fieldActions: strings.moreActionsAriaLabel, + }; + // Set when a switch goes through, so the menu or dialog closing behind it leaves focus on the + // new control rather than returning it to where it opened from. + const focusControlOnClose = useRef(false); + const keepControlFocus = (event: Event) => { + if (!focusControlOnClose.current) return; + focusControlOnClose.current = false; + event.preventDefault(); + (valueInputRef.current ?? modeControlRef.current)?.focus(); + }; + const selectMode = (next: QuickFormFieldMode) => { + if (!onModeChange) return; + const confirm = next !== mode && confirmModeChange?.(mode, next); + if (confirm) { + setPendingSwitch({ ...(confirm === true ? {} : confirm), to: next }); + return; + } + focusControlOnClose.current = next !== mode; + onModeChange(next); + }; + // Modes and actions share one menu. A field that cannot switch offers only its actions; with // neither, it keeps a disabled mode trigger so the field still shows how its value is expressed. + const switchable = !!onModeChange && valueModes.length > 1; const builtInMenu = ( onModeChange?.(next)} - modesDisabled={!onModeChange && menuActions.length > 0} - disabled={disabled || (!onModeChange && menuActions.length === 0)} + modes={[...valueModes]} + onSelect={selectMode} + modesDisabled={!switchable && menuActions.length > 0} + disabled={disabled || (!switchable && menuActions.length === 0)} actions={menuActions} triggerLabel={strings.valueModeAriaLabel} literalDescription={strings.literalDescriptions[fieldType] ?? typeMeta.fixedDescription} - strings={{ - literalTitle: strings.literalLabels[fieldType] ?? typeMeta.fixedLabel, - expressionTitle: strings.expressionLabel, - expressionDescription: strings.expressionDescription, - fieldActions: strings.moreActionsAriaLabel, - }} + strings={valueModeStrings} + onCloseAutoFocus={keepControlFocus} /> ); @@ -164,6 +227,8 @@ export function QuickFormField({ // Resolved before the tree so returning nothing falls back to the built-in control. const modeControl = renderModeControl?.(mode, controlProps) ?? null; + const builtInVariable = modeControl === null && mode === 'variable'; + const builtInPrompt = modeControl === null && mode === 'prompt'; // Switching mode is a prelude to writing the value, so focus the control. // Skipped on mount so rendering a form cannot steal focus. @@ -171,7 +236,12 @@ export function QuickFormField({ useEffect(() => { if (modeOnMount.current === mode) return; modeOnMount.current = mode; - valueInputRef.current?.focus(); + // A frame later: a popover trigger, as the variable picker is, remounts once + // its anchor registers, which would drop focus given now. + const frame = requestAnimationFrame(() => + (valueInputRef.current ?? modeControlRef.current)?.focus() + ); + return () => cancelAnimationFrame(frame); }, [mode]); // Enter blurs, so a consumer commits in `onValueBlur` as it already does. @@ -358,6 +428,12 @@ export function QuickFormField({ selected={parseListValue(value)} onChange={(selected) => editableOnValueChange?.(JSON.stringify(selected))} placeholder={strings.selectOptions} + searchPlaceholder={strings.multiSelectSearchPlaceholder} + emptyMessage={strings.multiSelectEmpty} + clearAllText={strings.multiSelectClearAll} + strings={strings.multiSelect} + // One line, as every other row is; chips past the width count as "+N more". + overflow="collapse" disabled={!editableOnValueChange} onBlur={onValueBlur} /> @@ -380,6 +456,42 @@ export function QuickFormField({ /> ); + // A pick replaces the binding. `onInsertVariable` takes it when supplied, so + // the consumer gets its own entry, type and all. + const canPickVariable = !disabled && (!!onInsertVariable || !!onValueChange); + const variableControl = ( + + onInsertVariable && item + ? onInsertVariable(variablePicker.resolve(item), { value, caret: null }) + : onValueChange?.(reference) + } + onBlur={onValueBlur} + inputRef={setModeControl} + variables={variablePicker.items} + disabled={!canPickVariable} + placeholder={placeholder} + strings={strings.valueModes} + aria-describedby={controlProps['aria-describedby']} + /> + ); + + const promptControl = ( + editableOnValueChange?.(next)} + onBlur={onValueBlur} + inputRef={setModeControl} + disabled={!editableOnValueChange} + placeholder={placeholder} + strings={strings.valueModes} + aria-describedby={controlProps['aria-describedby']} + /> + ); + return (
- onInsertVariable - ? onInsertVariable(variable, { value, caret: caretRef.current }) - : insertVariableAtCaret(variable.value) + // The variable picker is the value control there, so the header has nothing to insert into. + onInsertVariable={ + builtInVariable + ? undefined + : (variable) => + onInsertVariable + ? onInsertVariable(variable, { value, caret: caretRef.current }) + : insertVariableAtCaret(variable.value) } headerActions={headerActions} strings={strings} @@ -416,11 +532,12 @@ export function QuickFormField({ error={error} errorId={validationId} disabled={disabled} - // The dropzone and a text area are taller than a row and selected chips - // wrap past one, so the box grows to hold them. + // The agent tint marks the value as a prompt. The prompt, the dropzone and a text area + // are taller than a row, so the box grows to hold them. + variant={builtInPrompt ? 'agent' : undefined} layout={ - mode === 'literal' && - (fieldType === 'file' || fieldType === 'multi-select' || fieldType === 'textarea') + builtInPrompt || + (mode === 'literal' && (fieldType === 'file' || fieldType === 'textarea')) ? 'grow' : 'row' } @@ -447,6 +564,10 @@ export function QuickFormField({ ) : ( ) + ) : builtInVariable ? ( + variableControl + ) : builtInPrompt ? ( + promptControl ) : ( literalControl ))} @@ -459,6 +580,24 @@ export function QuickFormField({ {belowValue} + + setPendingSwitch(undefined)} + onConfirm={() => { + // The field may have been disabled while the dialog was open. + if (pendingSwitch && !disabled) { + focusControlOnClose.current = true; + onModeChange?.(pendingSwitch.to); + } + setPendingSwitch(undefined); + }} + onCloseAutoFocus={keepControlFocus} + />
); } 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 5015623ff4..1398626309 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 @@ -14,7 +14,8 @@ import { ToggleLeft, } from 'lucide-react'; import type { AriaAttributes, ReactNode } from 'react'; -import type { ValueMode } from '../field-addons/value-mode-strings'; +import type { ValueMode, ValueModeStrings } from '../field-addons/value-mode-strings'; +import type { MultiSelectStrings } from '../multi-select'; // Wind's value-mode vocabulary: a literal, a JS expression, a bound workflow // variable, or a prompt an agent fills in. @@ -188,9 +189,21 @@ export interface QuickFormFieldStrings { valueModeAriaLabel: string; expressionLabel: string; expressionDescription: string; + /** + * The variable and prompt modes' menu items, controls and placeholders, and the lossy-switch + * dialog. The literal and expression items keep `literalLabels`, `literalDescriptions`, + * `expressionLabel` and `expressionDescription`. + */ + valueModes: Partial; pickDate: string; selectOption: string; selectOptions: string; + /** The multi-select's search box, its empty state and its Clear all row, given the selected count. */ + multiSelectSearchPlaceholder: string; + multiSelectEmpty: string; + multiSelectClearAll: (count: number) => string; + /** The multi-select's create row, "+N more" count and remove names. */ + multiSelect: Partial; moreActionsAriaLabel: string; clearValue: string; forceRefresh: string; @@ -235,9 +248,14 @@ export const DEFAULT_QUICK_FORM_FIELD_STRINGS: QuickFormFieldStrings = { valueModeAriaLabel: 'Choose value mode', expressionLabel: 'Expression', expressionDescription: 'Use a JS expression', + valueModes: {}, pickDate: 'Pick a date', selectOption: 'Select an option', selectOptions: 'Select options...', + multiSelectSearchPlaceholder: 'Search...', + multiSelectEmpty: 'No items found.', + multiSelectClearAll: (count) => `Clear all (${count})`, + multiSelect: {}, moreActionsAriaLabel: 'More value actions', clearValue: 'Clear value', forceRefresh: 'Force refresh', @@ -270,6 +288,13 @@ export interface QuickFormFieldOption { type?: string; } +/** Words the dialog confirming a lossy mode switch; each falls back to `strings.valueModes`. */ +export interface QuickFormFieldModeSwitchConfirm { + title?: string; + description?: string; + confirmLabel?: string; +} + export interface QuickFormFieldMoreActions { onClear?: () => void; onRefresh?: () => void; @@ -331,17 +356,31 @@ export interface QuickFormFieldProps { */ leadingAddon?: ReactNode; /** - * Replaces the trailing fixed/expression menu with a consumer-provided - * action. Pass `null` to suppress the built-in - * menu; omitting the prop preserves it. + * Replaces the trailing value-mode 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'. */ mode?: QuickFormFieldMode; - /** Called when the user switches modes. */ + /** Called when the user switches modes, after any confirmation `confirmModeChange` asks for. */ onModeChange?: (mode: QuickFormFieldMode) => void; + /** + * The modes the built-in menu offers, in display order. Defaults to literal + * and expression. With fewer than two the menu offers no switch. + */ + valueModes?: readonly QuickFormFieldMode[]; + /** + * Asks before a switch that would lose the current value. Only the consumer + * knows how its value maps between modes, so it decides: return `true` to + * confirm with the default dialog text, an object to word it, or `false` to + * switch straight away. Omit to never confirm. + */ + confirmModeChange?: ( + from: QuickFormFieldMode, + to: QuickFormFieldMode + ) => boolean | QuickFormFieldModeSwitchConfirm; /** * Optional expression editor used in place of the built-in monospace input. * Consumers can use this to supply a syntax-aware editor such as Monaco. @@ -352,13 +391,12 @@ export interface QuickFormFieldProps { */ 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 - * its own choices can replace the `literal` control too. + * Renders the value control for a given mode, replacing the built-in one, + * such as a surface that authors its own choices in `literal` mode. * - * Returning nothing (or omitting this) falls back to the built-in control - * for the field's type, so an unhandled mode degrades to the literal editor - * rather than rendering an empty row. + * Returning nothing (or omitting this) keeps the built-in control for the + * mode: a picker over `variables` for `variable`, an agent prompt textarea + * for `prompt`, and the field type's own control otherwise. */ renderModeControl?: (mode: QuickFormFieldMode, props: QuickFormFieldControlProps) => ReactNode; /** @@ -369,6 +407,9 @@ export interface QuickFormFieldProps { * A value that is one reference must be replaced rather than appended, and a * variable may carry its type onto the field, so the consumer decides. Omit * to splice at the caret, or append when no caret has been placed. + * + * Also called when a variable is picked in `variable` mode, where the value + * is that one reference and the pick replaces it. */ onInsertVariable?: ( variable: QuickFormFieldOption, @@ -426,8 +467,9 @@ export interface QuickFormFieldProps { */ onGenerateWithAi?: (prompt: string) => void; /** - * Variables offered by the "Insert variable" popover; clicking one appends its value to the - * current value. The button is disabled when this is empty (the default). + * Variables offered by the "Insert variable" popover, and by the picker in + * `variable` mode. Inserting one appends its value to the current value. The + * Insert button is disabled when this is empty (the default). */ variables?: QuickFormFieldOption[]; id?: string; 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 349fc0541a..fd888671bf 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,3 +1,4 @@ +import type { VariablePickerItem } from '@/components/ui/variable-picker'; import { toDateValue } from '@/lib/date-value'; import type { QuickFormFieldOption } from './types'; @@ -31,3 +32,29 @@ export function formatDateValue(value: string): string { ? date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : value; } + +export interface VariablePickerSource { + items: VariablePickerItem[]; + /** The consumer's own entry for a picked item: rebuilding it from the item would drop `type`. */ + resolve: (item: VariablePickerItem) => QuickFormFieldOption; +} + +/** Maps `variables` onto picker items, keeping a way back to each consumer entry. */ +export function toVariablePickerSource(variables: QuickFormFieldOption[]): VariablePickerSource { + // Ids come from tree position: neither labels nor values are required to be unique. + const byId = new Map(); + const toItem = (v: QuickFormFieldOption, id: string): VariablePickerItem => { + byId.set(id, v); + return { + id, + label: v.label, + ...(v.value ? { value: v.value } : {}), + ...(v.type ? { type: v.type } : {}), + ...(v.children ? { children: v.children.map((c, i) => toItem(c, `${id}/${i}`)) } : {}), + }; + }; + return { + items: variables.map((v, i) => toItem(v, String(i))), + resolve: (item) => byId.get(item.id) ?? { label: item.label, value: item.value ?? '' }, + }; +} diff --git a/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx b/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx index c7f34a185e..c724d7c1b4 100644 --- a/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx +++ b/packages/apollo-wind/src/components/ui/variable-value-control.test.tsx @@ -48,7 +48,10 @@ describe('VariableValueControl', () => { await user.click(screen.getByRole('button', { name: 'Ref' })); expect(source).toHaveBeenCalledTimes(1); await user.click(await screen.findByText('Order id')); - expect(onChange).toHaveBeenCalledWith('$vars.orderId'); + expect(onChange).toHaveBeenCalledWith( + '$vars.orderId', + expect.objectContaining({ label: 'Order id', value: '$vars.orderId' }) + ); expect(screen.queryByPlaceholderText('Search variables...')).toBeNull(); }); @@ -127,7 +130,8 @@ describe('VariableValueControl', () => { expect(await screen.findByText('Bound: $vars.old')).toBeInTheDocument(); expect(screen.queryByPlaceholderText('Search variables...')).toBeNull(); await user.click(screen.getByRole('button', { name: 'Host order id' })); - expect(onChange).toHaveBeenCalledWith('$host.orderId'); + // A host pick is the reference alone: there is no entry to hand on. + expect(onChange).toHaveBeenCalledWith('$host.orderId', undefined); expect(screen.queryByText('Bound: $vars.old')).toBeNull(); expect(trigger).toHaveFocus(); }); diff --git a/packages/apollo-wind/src/components/ui/variable-value-control.tsx b/packages/apollo-wind/src/components/ui/variable-value-control.tsx index 3887a28d9e..338b051966 100644 --- a/packages/apollo-wind/src/components/ui/variable-value-control.tsx +++ b/packages/apollo-wind/src/components/ui/variable-value-control.tsx @@ -31,7 +31,11 @@ interface VariableValueControlBaseProps { name?: string; /** The bound reference, such as `$vars.orderId`. */ value?: string; - onChange: (reference: string) => void; + /** + * Also gets the picked entry, as supplied in `variables`, for a consumer that reads more than the + * reference, such as its type. A host picker reports only the reference, so its pick has none. + */ + onChange: (reference: string, item?: VariablePickerItem) => void; /** Called when the trigger loses focus, as a form library marks a field touched. */ onBlur?: () => void; /** Also gets the trigger, as a form library's field ref does to focus an invalid field. */ @@ -127,16 +131,18 @@ const VariableValueControl = React.forwardRef setOpen(false), []); const commit = React.useCallback( - (next: string) => { - if (next) onChange(next); + (next: string, item?: VariablePickerItem) => { + if (next) onChange(next, item); setOpen(false); }, [onChange] ); const select = React.useCallback( - (item: VariablePickerItem) => commit(item.value ?? ''), + (item: VariablePickerItem) => commit(item.value ?? '', item), [commit] ); + // One argument, so nothing a host passes beyond the value can stand in for an entry. + const hostSelect = React.useCallback((next: string) => commit(next), [commit]); return ( @@ -163,7 +169,7 @@ const VariableValueControl = React.forwardRef ) : ( diff --git a/packages/apollo-wind/src/index.ts b/packages/apollo-wind/src/index.ts index e113add31a..3eb8120896 100644 --- a/packages/apollo-wind/src/index.ts +++ b/packages/apollo-wind/src/index.ts @@ -867,6 +867,7 @@ export type { QuickFieldType, QuickFormFieldControlProps, QuickFormFieldMode, + QuickFormFieldModeSwitchConfirm, QuickFormFieldMoreActions, QuickFormFieldOption, QuickFormFieldProps,