mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
fix: expand template editor authoring surface
This commit is contained in:
parent
55b166f3bc
commit
311879be50
3 changed files with 357 additions and 140 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
131
web/src/__tests__/template-editor-dialog-mantine.test.tsx
Normal file
131
web/src/__tests__/template-editor-dialog-mantine.test.tsx
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue