fix: expand template editor authoring surface

This commit is contained in:
Brad Groux 2026-07-24 13:26:12 -05:00
parent 55b166f3bc
commit 311879be50
3 changed files with 357 additions and 140 deletions

View file

@ -36,6 +36,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed competing profile-editor and scorer-list scroll containers so Agent
Output Scoring uses the application page scrollbar at compact and full
desktop sizes (#938).
- Expanded the template editor into a responsive authoring surface with one
bounded scroll region, fixed actions, a resizable Markdown editor, inline
validation, and unsaved-change protection (#941).
## [6.0.0] - 2026-07-24

View file

@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TemplateEditorDialog } from '@/components/templates/TemplateEditorDialog';
import { renderWithProviders } from './test-utils';
import type { TaskTemplate } from '@/hooks/useTemplates';
const mocks = vi.hoisted(() => ({
createTemplate: vi.fn(),
updateTemplate: vi.fn(),
toast: vi.fn(),
}));
vi.mock('@/hooks/useTemplates', () => ({
useCreateTemplate: () => ({
mutateAsync: mocks.createTemplate,
isPending: false,
}),
useUpdateTemplate: () => ({
mutateAsync: mocks.updateTemplate,
isPending: false,
}),
}));
vi.mock('@/hooks/useTaskTypes', () => ({
useTaskTypesManager: () => ({
items: [{ id: 'feature', label: 'Feature', icon: 'sparkles' }],
}),
getTypeIcon: () => () => null,
}));
vi.mock('@/hooks/useToast', () => ({
useToast: () => ({ toast: mocks.toast }),
}));
const longMarkdown = Array.from(
{ length: 30 },
(_, index) => `## Step ${index + 1}\n\n- Verify outcome ${index + 1}`
).join('\n\n');
const template: TaskTemplate = {
id: 'template-long',
name: 'Long authoring template',
description: 'Exercises a long Markdown task description.',
category: 'feature',
version: 1,
taskDefaults: {
type: 'feature',
priority: 'high',
project: 'veritas-kanban',
agent: 'gpt-4',
descriptionTemplate: longMarkdown,
},
created: '2026-07-24T00:00:00.000Z',
updated: '2026-07-24T00:00:00.000Z',
};
describe('TemplateEditorDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.createTemplate.mockResolvedValue({});
mocks.updateTemplate.mockResolvedValue({});
});
afterEach(() => {
cleanup();
});
it('uses one bounded scroll region with fixed actions and a useful Markdown editor', async () => {
const user = userEvent.setup();
const { baseElement } = renderWithProviders(
<TemplateEditorDialog template={template} open onOpenChange={vi.fn()} />
);
const modal = baseElement.querySelector('.mantine-Modal-content') as HTMLElement;
const scrollRegion = screen.getByTestId('template-editor-scroll-region');
const actions = screen.getByTestId('template-editor-actions');
expect(modal.className).toContain('h-[min(780px,calc(100dvh-2rem))]');
expect(modal.className).toContain('max-h-[calc(100dvh-2rem)]');
expect(scrollRegion.className).toContain('overflow-y-auto');
expect(scrollRegion.getAttribute('tabindex')).toBe('0');
expect(scrollRegion.contains(actions)).toBe(false);
await user.click(screen.getByRole('tab', { name: 'Task Defaults' }));
const markdownEditor = screen.getByRole('textbox', { name: 'Description Template' });
expect((markdownEditor as HTMLTextAreaElement).value).toBe(longMarkdown);
expect((markdownEditor as HTMLTextAreaElement).style.minHeight).toBe('240px');
expect((markdownEditor as HTMLTextAreaElement).style.resize).toBe('vertical');
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined();
expect(screen.getByRole('button', { name: 'Update Template' })).toBeDefined();
});
it('warns before closing a dirty editor and keeps the modal open when declined', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
const confirmDiscard = vi.spyOn(window, 'confirm').mockReturnValue(false);
renderWithProviders(
<TemplateEditorDialog template={template} open onOpenChange={onOpenChange} />
);
await user.clear(screen.getByRole('textbox', { name: /Template Name/i }));
await user.type(screen.getByRole('textbox', { name: /Template Name/i }), 'Changed template');
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(confirmDiscard).toHaveBeenCalledWith('Discard unsaved template changes?');
expect(onOpenChange).not.toHaveBeenCalled();
confirmDiscard.mockReturnValue(true);
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
confirmDiscard.mockRestore();
});
it('shows inline validation and does not create a nameless template', () => {
renderWithProviders(<TemplateEditorDialog template={null} open onOpenChange={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Create Template' }));
expect(screen.getByText('Template name is required')).toBeDefined();
expect(mocks.createTemplate).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Validation Error',
variant: 'destructive',
})
);
});
});

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Group,
@ -23,6 +23,46 @@ interface TemplateEditorDialogProps {
onOpenChange: (open: boolean) => void;
}
interface TemplateFormValues {
name: string;
description: string;
category: string;
type: string;
priority: TaskPriority | '';
project: string;
agent: AgentType | '';
descriptionTemplate: string;
}
const EMPTY_FORM_VALUES: TemplateFormValues = {
name: '',
description: '',
category: '',
type: '',
priority: '',
project: '',
agent: '',
descriptionTemplate: '',
};
function formValuesForTemplate(template: TaskTemplate | null): TemplateFormValues {
if (!template) return EMPTY_FORM_VALUES;
return {
name: template.name,
description: template.description || '',
category: template.category || '',
type: template.taskDefaults?.type || '',
priority: (template.taskDefaults?.priority as TaskPriority) || '',
project: template.taskDefaults?.project || '',
agent: (template.taskDefaults?.agent as AgentType) || '',
descriptionTemplate: template.taskDefaults?.descriptionTemplate || '',
};
}
function serializeForm(values: TemplateFormValues): string {
return JSON.stringify(values);
}
export function TemplateEditorDialog({ template, open, onOpenChange }: TemplateEditorDialogProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
@ -32,6 +72,8 @@ export function TemplateEditorDialog({ template, open, onOpenChange }: TemplateE
const [project, setProject] = useState('');
const [agent, setAgent] = useState<AgentType | ''>('');
const [descriptionTemplate, setDescriptionTemplate] = useState('');
const [initialSnapshot, setInitialSnapshot] = useState(() => serializeForm(EMPTY_FORM_VALUES));
const [showValidationErrors, setShowValidationErrors] = useState(false);
const { toast } = useToast();
const { items: taskTypes } = useTaskTypesManager();
@ -59,35 +101,50 @@ export function TemplateEditorDialog({ template, open, onOpenChange }: TemplateE
{ value: 'gpt-4', label: 'GPT-4' },
];
// Populate form when editing
const currentValues: TemplateFormValues = {
name,
description,
category,
type,
priority,
project,
agent,
descriptionTemplate,
};
const isDirty = open && serializeForm(currentValues) !== initialSnapshot;
const applyFormValues = useCallback((values: TemplateFormValues) => {
setName(values.name);
setDescription(values.description);
setCategory(values.category);
setType(values.type);
setPriority(values.priority);
setProject(values.project);
setAgent(values.agent);
setDescriptionTemplate(values.descriptionTemplate);
}, []);
useEffect(() => {
if (template) {
setName(template.name);
setDescription(template.description || '');
setCategory(template.category || '');
setType(template.taskDefaults?.type || '');
setPriority((template.taskDefaults?.priority as TaskPriority) || '');
setProject(template.taskDefaults?.project || '');
setAgent((template.taskDefaults?.agent as AgentType) || '');
setDescriptionTemplate(template.taskDefaults?.descriptionTemplate || '');
} else {
resetForm();
}
}, [template, open]);
const values = formValuesForTemplate(template);
applyFormValues(values);
setInitialSnapshot(serializeForm(values));
setShowValidationErrors(false);
}, [applyFormValues, template, open]);
const resetForm = () => {
setName('');
setDescription('');
setCategory('');
setType('');
setPriority('');
setProject('');
setAgent('');
setDescriptionTemplate('');
applyFormValues(EMPTY_FORM_VALUES);
setInitialSnapshot(serializeForm(EMPTY_FORM_VALUES));
setShowValidationErrors(false);
};
const requestClose = () => {
if (isDirty && !window.confirm('Discard unsaved template changes?')) return;
onOpenChange(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setShowValidationErrors(true);
if (!name.trim()) {
toast({
@ -140,136 +197,162 @@ export function TemplateEditorDialog({ template, open, onOpenChange }: TemplateE
return (
<Modal
opened={open}
onClose={() => onOpenChange(false)}
onClose={requestClose}
title={template ? 'Edit Template' : 'Create New Template'}
size="lg"
size="min(960px, calc(100vw - 2rem))"
centered
classNames={{
content:
'flex h-[min(780px,calc(100dvh-2rem))] max-h-[calc(100dvh-2rem)] flex-col overflow-hidden',
header: 'shrink-0',
body: 'min-h-0 flex-1 overflow-hidden p-0',
}}
>
<form onSubmit={handleSubmit}>
<Stack gap="lg">
<Tabs defaultValue="basic" className="w-full">
<Tabs.List grow>
<Tabs.Tab value="basic">Basic Info</Tabs.Tab>
<Tabs.Tab value="defaults">Task Defaults</Tabs.Tab>
</Tabs.List>
<form onSubmit={handleSubmit} className="flex h-full min-h-0 flex-col">
<div
data-testid="template-editor-scroll-region"
className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 sm:px-6"
tabIndex={0}
>
<Stack gap="lg">
<Tabs defaultValue="basic" className="w-full">
<Tabs.List className="w-fit max-w-full">
<Tabs.Tab value="basic">Basic Info</Tabs.Tab>
<Tabs.Tab value="defaults">Task Defaults</Tabs.Tab>
</Tabs.List>
{/* Basic Info Tab */}
<Tabs.Panel value="basic" className="space-y-4 mt-4">
<div className="grid gap-4">
<TextInput
id="name"
label={
<>
Template Name <span className="text-destructive">*</span>
</>
}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Bug Fix, Feature Implementation"
required
/>
<Textarea
id="description"
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What is this template used for?"
rows={3}
/>
<Select
id="category"
label="Category"
value={category || null}
onChange={(value) => setCategory(value ?? '')}
data={categoryOptions}
placeholder="Select a category..."
/>
</div>
</Tabs.Panel>
{/* Task Defaults Tab */}
<Tabs.Panel value="defaults" className="space-y-4 mt-4">
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-4">
<Select
id="type"
label="Default Type"
value={type || null}
onChange={(value) => setType(value ?? '')}
data={taskTypeOptions}
placeholder="Any"
renderOption={({ option }) => {
const iconName = taskTypeOptions.find(
(entry) => entry.value === option.value
)?.icon;
const IconComponent = iconName ? getTypeIcon(iconName) : null;
return (
<Group gap="xs">
{IconComponent && <IconComponent className="h-4 w-4" />}
<span>{option.label}</span>
</Group>
);
}}
/>
<Select
id="priority"
label="Default Priority"
value={priority || null}
onChange={(value) => setPriority((value as TaskPriority | null) ?? '')}
data={priorityOptions}
placeholder="None"
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Basic Info Tab */}
<Tabs.Panel value="basic" className="space-y-4 mt-4">
<div className="grid gap-4">
<TextInput
id="project"
label="Default Project"
value={project}
onChange={(e) => setProject(e.target.value)}
placeholder="e.g., VK-001"
id="name"
label={
<>
Template Name <span className="text-destructive">*</span>
</>
}
value={name}
onChange={(e) => {
setName(e.target.value);
if (showValidationErrors && e.target.value.trim()) {
setShowValidationErrors(false);
}
}}
placeholder="e.g., Bug Fix, Feature Implementation"
error={
showValidationErrors && !name.trim() ? 'Template name is required' : undefined
}
aria-required="true"
/>
<Textarea
id="description"
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What is this template used for?"
rows={3}
/>
<Select
id="agent"
label="Default Agent"
value={agent || null}
onChange={(value) => setAgent((value as AgentType | null) ?? '')}
data={agentOptions}
placeholder="None"
id="category"
label="Category"
value={category || null}
onChange={(value) => setCategory(value ?? '')}
data={categoryOptions}
placeholder="Select a category..."
/>
</div>
</Tabs.Panel>
<div className="grid gap-2">
<Textarea
id="descriptionTemplate"
label="Description Template"
value={descriptionTemplate}
onChange={(e) => setDescriptionTemplate(e.target.value)}
placeholder="Template for task description (can include variables like {{date}}, {{project}})"
rows={4}
/>
<Text size="xs" c="dimmed">
Tip: Use variables like {'{{date}}'} to auto-populate values
</Text>
{/* Task Defaults Tab */}
<Tabs.Panel value="defaults" className="space-y-4 mt-4">
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Select
id="type"
label="Default Type"
value={type || null}
onChange={(value) => setType(value ?? '')}
data={taskTypeOptions}
placeholder="Any"
renderOption={({ option }) => {
const iconName = taskTypeOptions.find(
(entry) => entry.value === option.value
)?.icon;
const IconComponent = iconName ? getTypeIcon(iconName) : null;
return (
<Group gap="xs">
{IconComponent && <IconComponent className="h-4 w-4" />}
<span>{option.label}</span>
</Group>
);
}}
/>
<Select
id="priority"
label="Default Priority"
value={priority || null}
onChange={(value) => setPriority((value as TaskPriority | null) ?? '')}
data={priorityOptions}
placeholder="None"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<TextInput
id="project"
label="Default Project"
value={project}
onChange={(e) => setProject(e.target.value)}
placeholder="e.g., VK-001"
/>
<Select
id="agent"
label="Default Agent"
value={agent || null}
onChange={(value) => setAgent((value as AgentType | null) ?? '')}
data={agentOptions}
placeholder="None"
/>
</div>
<div className="grid gap-2">
<Textarea
id="descriptionTemplate"
label="Description Template"
value={descriptionTemplate}
onChange={(e) => setDescriptionTemplate(e.target.value)}
placeholder="Template for task description (can include variables like {{date}}, {{project}})"
minRows={12}
aria-label="Description Template"
styles={{ input: { minHeight: 240, resize: 'vertical' } }}
/>
<Text size="xs" c="dimmed">
Tip: Use variables like {'{{date}}'} to auto-populate values
</Text>
</div>
</div>
</div>
</Tabs.Panel>
</Tabs>
</Tabs.Panel>
</Tabs>
</Stack>
</div>
<Group justify="flex-end">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{template ? 'Update Template' : 'Create Template'}
</Button>
</Group>
</Stack>
<Group
data-testid="template-editor-actions"
justify="flex-end"
className="shrink-0 border-t bg-card px-4 py-4 sm:px-6"
>
<Button type="button" variant="outline" onClick={requestClose}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{template ? 'Update Template' : 'Create Template'}
</Button>
</Group>
</form>
</Modal>
);