Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/apollo-wind/docs/AI_CONSUMER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FieldMenu mode="literal" onSelect={vi.fn()} onCloseAutoFocus={onCloseAutoFocus} />);
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(
<FieldMenu mode="expression" onSelect={vi.fn()} triggerLabel="Choose value mode" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ interface FieldMenuBaseProps<Id extends string> {
triggerLabel?: string;
/** Overrides for any subset of the English strings. */
strings?: Partial<ValueModeStrings>;
/**
* 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;
}

/**
Expand Down Expand Up @@ -142,6 +147,7 @@ export function FieldMenu<Id extends string = ValueMode>({
literalDescription,
triggerLabel: triggerLabelOverride,
strings,
onCloseAutoFocus,
}: FieldMenuProps<Id>) {
const text = { ...DEFAULT_VALUE_MODE_STRINGS, ...strings };
const checkedMode = checked ?? mode;
Expand Down Expand Up @@ -183,7 +189,7 @@ export function FieldMenu<Id extends string = ValueMode>({
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent align="end" className="w-56">
<DropdownMenuContent align="end" className="w-56" onCloseAutoFocus={onCloseAutoFocus}>
{!modesDisabled &&
resolvedModes.map((item) => (
<ValueModeMenuItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ describe('ValueModeSwitchDialog', () => {
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(
<ValueModeSwitchDialog
open
onConfirm={vi.fn()}
onCancel={vi.fn()}
onCloseAutoFocus={onCloseAutoFocus}
/>
);
rerender(
<ValueModeSwitchDialog
open={false}
onConfirm={vi.fn()}
onCancel={vi.fn()}
onCloseAutoFocus={onCloseAutoFocus}
/>
);
await user.keyboard('{Tab}');
expect(onCloseAutoFocus).toHaveBeenCalledOnce();
});

it("prefers the codec's own wording, then the given strings", () => {
render(
<ValueModeSwitchDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface ValueModeSwitchDialogProps {
confirmLabel?: string;
/** Overrides for any subset of the English strings. */
strings?: Partial<ValueModeStrings>;
/**
* 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. */
Expand All @@ -33,11 +38,12 @@ export function ValueModeSwitchDialog({
description,
confirmLabel,
strings,
onCloseAutoFocus,
}: ValueModeSwitchDialogProps) {
const text = { ...DEFAULT_VALUE_MODE_STRINGS, ...strings };
return (
<AlertDialog open={open} onOpenChange={(next) => !next && onCancel()}>
<AlertDialogContent>
<AlertDialogContent onCloseAutoFocus={onCloseAutoFocus}>
<AlertDialogHeader>
<AlertDialogTitle>{title ?? text.lossyTitle}</AlertDialogTitle>
<AlertDialogDescription>{description ?? text.lossyDescription}</AlertDialogDescription>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, QuickFormFieldOption>();
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');
Expand Down Expand Up @@ -228,27 +208,22 @@ export function FieldHeader({
}}
/>
)}
<InsertVariableAction
// Rendered as supplied, so what the tree shows is what it inserts.
variables={pickerItems}
compact={compact}
disabled={!onValueChange}
strings={{
label: strings.insertLabel,
ariaLabel: strings.insertAriaLabel,
searchPlaceholder: strings.insertSearchPlaceholder,
empty: strings.insertEmpty,
}}
// The parent owns the caret, so the header only reports the choice. Hand back
// the consumer's own entry: rebuilding it from the picker item would drop `type`.
onInsert={
onInsertVariable &&
((value, item) =>
onInsertVariable(
optionsBySelectionId.get(item.id) ?? { label: item.label, value }
))
}
/>
{onInsertVariable && (
<InsertVariableAction
// Rendered as supplied, so what the tree shows is what it inserts.
variables={variables.items}
compact={compact}
disabled={!onValueChange}
strings={{
label: strings.insertLabel,
ariaLabel: strings.insertAriaLabel,
searchPlaceholder: strings.insertSearchPlaceholder,
empty: strings.insertEmpty,
}}
// The parent owns the caret, so the header only reports the choice.
onInsert={(_, item) => onInsertVariable(variables.resolve(item))}
/>
)}
</>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type {
QuickFieldType,
QuickFormFieldControlProps,
QuickFormFieldMode,
QuickFormFieldModeSwitchConfirm,
QuickFormFieldMoreActions,
QuickFormFieldOption,
QuickFormFieldProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<QuickFormField locked={false} mode="variable" onValueChange={vi.fn()} />);
expect(screen.getByPlaceholderText('String value')).toBeInTheDocument();
render(
<QuickFormField
locked={false}
mode="variable"
onValueChange={vi.fn()}
renderModeControl={() => null}
/>
);
expect(screen.getByText('Select a variable')).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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
Expand Down Expand Up @@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton icon size="3xs" aria-label="Choose value mode">
<active.icon />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{MODE_ITEMS.map((item) => (
<DropdownMenuItem key={item.mode} onClick={() => onModeChange(item.mode)}>
<item.icon />
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

function ValueModesDemo() {
const fieldId = useId();
const [mode, setMode] = useState<QuickFormFieldMode>('variable');
Expand All @@ -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 (
<div className="w-80">
Expand All @@ -500,39 +464,10 @@ function ValueModesDemo() {
showLock={false}
showFieldActions={false}
mode={mode}
onModeChange={setMode}
trailingAddon={<FourModeMenu mode={mode} onModeChange={setMode} />}
renderModeControl={(activeMode, control) => {
if (activeMode === 'variable') {
return (
<Select value={control.value} onValueChange={control.onValueChange}>
<SelectTrigger id={control.id} className="min-w-0 flex-1">
<SelectValue placeholder="Select a variable" />
</SelectTrigger>
<SelectContent>
{WORKFLOW_VARIABLES.map((variable) => (
<SelectItem key={variable.value} value={variable.value}>
{variable.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (activeMode === 'prompt') {
return (
<InputGroupInput
id={control.id}
value={control.value}
onChange={(event) => 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}
/>
</div>
);
Expand All @@ -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.',
},
},
},
Expand Down
Loading
Loading