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
5 changes: 4 additions & 1 deletion src/components/block-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ const BLOCK_TYPE_LABELS: Record<SupportedBlockType, string> = {
card: 'Card',
carousel: 'Carousel',
context_actions: 'Context Actions',
input: 'Input'
input: 'Input',
video: 'Video',
plan: 'Plan',
task_card: 'Task Card'
};

/**
Expand Down
14 changes: 13 additions & 1 deletion src/components/editors/block-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ import type {
ContextActionsBlock,
InputBlock,
MarkdownBlock,
PlanBlock,
SupportedBlock,
SupportedBlockType,
TableBlock
TableBlock,
TaskCardBlock,
VideoBlock
} from '../../types';
import { ActionsEditor } from './actions-editor';
import { AlertEditor } from './alert-editor';
Expand All @@ -29,9 +32,12 @@ import { HeaderEditor } from './header-editor';
import { ImageEditor } from './image-editor';
import { InputEditor } from './input-editor';
import { MarkdownEditor } from './markdown-editor';
import { PlanEditor } from './plan-editor';
import { RichTextEditor } from './rich-text-editor';
import { SectionEditor } from './section-editor';
import { TableEditor } from './table-editor';
import { TaskCardEditor } from './task-card-editor';
import { VideoEditor } from './video-editor';

/**
* Dispatches to the correct per-block editor form. Provides a consistent
Expand Down Expand Up @@ -107,6 +113,12 @@ function dispatch(block: SupportedBlock, onChange: (next: SupportedBlock) => voi
return <ContextActionsEditor block={block as ContextActionsBlock} onChange={(next) => onChange(next)} />;
case 'input':
return <InputEditor block={block as InputBlock} onChange={(next) => onChange(next)} />;
case 'video':
return <VideoEditor block={block as VideoBlock} onChange={(next) => onChange(next)} />;
case 'plan':
return <PlanEditor block={block as PlanBlock} onChange={(next) => onChange(next)} />;
case 'task_card':
return <TaskCardEditor block={block as TaskCardBlock} onChange={(next) => onChange(next)} />;
default:
return null;
}
Expand Down
118 changes: 118 additions & 0 deletions src/components/editors/plan-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Plus, Trash2 } from 'lucide-react';
import { nanoid } from 'nanoid';
import { Button } from '../../lib/ui/button';
import { Input } from '../../lib/ui/input';
import { Label } from '../../lib/ui/label';
import { RadioGroup, RadioGroupItem } from '../../lib/ui/radio-group';
import type { PlanBlock, TaskCardBlock, TaskCardStatus } from '../../types';
import { EditorField } from './field';
import type { BlockEditorProps } from './types';

const STATUSES: readonly TaskCardStatus[] = ['pending', 'in_progress', 'complete', 'error'] as const;

/**
* Editor form for plan blocks. Edits the title and the inline tasks list.
* Each task gets a compact title + status row; richer fields (sources,
* details, output) round-trip on the payload but aren't editable inline —
* use a standalone `task_card` block for those.
* @param props - editor props
* @param props.block - the plan block to edit
* @param props.onChange - called with the updated block payload
* @returns the rendered plan editor form
*/
export function PlanEditor({ block, onChange }: BlockEditorProps<PlanBlock>) {
const titleText = typeof block.title === 'string' ? block.title : (block.title?.text ?? '');
const tasks = block.tasks ?? [];

const updateTask = (idx: number, change: Partial<TaskCardBlock>) => {
onChange({
...block,
tasks: tasks.map((t, i) => (i === idx ? { ...t, ...change } : t))
});
};
const removeTask = (idx: number) => {
const next = tasks.filter((_, i) => i !== idx);
onChange({ ...block, tasks: next.length > 0 ? next : undefined });
};
const addTask = () => {
onChange({
...block,
tasks: [
...tasks,
{
type: 'task_card',
task_id: `task_${nanoid(6)}`,
title: 'New step',
status: 'pending'
}
]
});
};

return (
<div className="flex flex-col gap-4">
<EditorField label="Title" help="Heading shown above the task list." htmlFor="plan-title">
<Input
id="plan-title"
value={titleText}
placeholder="e.g. Investigating the issue"
onChange={(e) => onChange({ ...block, title: e.target.value })}
/>
</EditorField>

<div className="flex flex-col gap-2 rounded-md border bg-muted/20 p-3">
<span className="text-xs font-medium text-foreground">Tasks</span>
{tasks.length === 0 ? (
<p className="text-[11px] leading-snug text-muted-foreground">
No tasks. Add one to render a checklist beneath the title.
</p>
) : null}
{tasks.map((task, idx) => {
const status: TaskCardStatus = task.status ?? 'pending';
return (
<div key={idx} className="flex flex-col gap-2 rounded border bg-background p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">Task {idx + 1}</span>
<button
type="button"
aria-label="Remove task"
onClick={() => removeTask(idx)}
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<EditorField label="Title" htmlFor={`plan-task-title-${idx}`}>
<Input
id={`plan-task-title-${idx}`}
value={task.title}
placeholder="e.g. Reproduce the error locally"
onChange={(e) => updateTask(idx, { title: e.target.value })}
/>
</EditorField>
<EditorField label="Status">
<RadioGroup
value={status}
onValueChange={(v) => updateTask(idx, { status: v as TaskCardStatus })}
className="flex flex-row flex-wrap gap-3"
>
{STATUSES.map((s) => (
<div key={s} className="flex items-center gap-1.5">
<RadioGroupItem value={s} id={`plan-task-status-${idx}-${s}`} />
<Label htmlFor={`plan-task-status-${idx}-${s}`} className="text-xs capitalize">
{s.replace('_', ' ')}
</Label>
</div>
))}
</RadioGroup>
</EditorField>
</div>
);
})}
<Button type="button" size="sm" onClick={addTask} className="self-start">
<Plus className="h-3.5 w-3.5" /> Add task
</Button>
</div>
</div>
);
}
141 changes: 141 additions & 0 deletions src/components/editors/task-card-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '../../lib/ui/button';
import { Input } from '../../lib/ui/input';
import { Label } from '../../lib/ui/label';
import { RadioGroup, RadioGroupItem } from '../../lib/ui/radio-group';
import type { TaskCardBlock, TaskCardStatus, UrlSourceElement } from '../../types';
import { EditorField } from './field';
import type { BlockEditorProps } from './types';

const STATUSES: readonly TaskCardStatus[] = ['pending', 'in_progress', 'complete', 'error'] as const;

/**
* Editor form for task_card blocks. Edits the task id, title, status, and
* the cited sources list. The rich-text `details` and `output` fields
* round-trip through the payload but are not editable in the visual
* builder — palette variants that include them keep them on save.
* @param props - editor props
* @param props.block - the task_card block to edit
* @param props.onChange - called with the updated block payload
* @returns the rendered task_card editor form
*/
export function TaskCardEditor({ block, onChange }: BlockEditorProps<TaskCardBlock>) {
const status: TaskCardStatus = block.status ?? 'pending';
return (
<div className="flex flex-col gap-4">
<EditorField label="Title" help="Short label shown at the top of the card." htmlFor="task-card-title">
<Input
id="task-card-title"
value={block.title}
placeholder="e.g. Reproduce the bug locally"
onChange={(e) => onChange({ ...block, title: e.target.value })}
/>
</EditorField>

<EditorField
label="Task ID"
help="Stable identifier you can reference from interaction payloads."
htmlFor="task-card-id"
>
<Input
id="task-card-id"
value={block.task_id}
placeholder="e.g. task_1"
onChange={(e) => onChange({ ...block, task_id: e.target.value })}
/>
</EditorField>

<EditorField label="Status" help="Slack renders a matching status chip on the card.">
<RadioGroup
value={status}
onValueChange={(v) => onChange({ ...block, status: v as TaskCardStatus })}
className="flex flex-row flex-wrap gap-3"
>
{STATUSES.map((s) => (
<div key={s} className="flex items-center gap-1.5">
<RadioGroupItem value={s} id={`task-card-status-${s}`} />
<Label htmlFor={`task-card-status-${s}`} className="text-xs capitalize">
{s.replace('_', ' ')}
</Label>
</div>
))}
</RadioGroup>
</EditorField>

<SourcesField
sources={block.sources ?? []}
onChange={(next) => onChange({ ...block, sources: next.length > 0 ? next : undefined })}
/>
</div>
);
}

/**
* Sub-editor for the task card's `sources` list. Each source is a
* `{ text, url }` pair Slack renders as a labeled link beneath the card.
* @param props - field props
* @param props.sources - the current sources list
* @param props.onChange - called with the updated sources list
* @returns the rendered sources editor
*/
function SourcesField({
sources,
onChange
}: {
sources: UrlSourceElement[];
onChange: (next: UrlSourceElement[]) => void;
}) {
const update = (idx: number, change: Partial<UrlSourceElement>) => {
onChange(sources.map((s, i) => (i === idx ? { ...s, ...change } : s)));
};
const removeAt = (idx: number) => onChange(sources.filter((_, i) => i !== idx));
const addSource = () => {
onChange([...sources, { type: 'url', url: '', text: '' }]);
};

return (
<div className="flex flex-col gap-2 rounded-md border bg-muted/20 p-3">
<span className="text-xs font-medium text-foreground">Sources</span>
{sources.length === 0 ? (
<p className="text-[11px] leading-snug text-muted-foreground">
No sources. Add one to cite a document or link beneath the card.
</p>
) : null}
{sources.map((src, idx) => (
<div key={idx} className="flex flex-col gap-2 rounded border bg-background p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">Source {idx + 1}</span>
<button
type="button"
aria-label="Remove source"
onClick={() => removeAt(idx)}
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<EditorField label="Label" htmlFor={`task-card-source-text-${idx}`}>
<Input
id={`task-card-source-text-${idx}`}
value={src.text}
placeholder="e.g. Runbook"
onChange={(e) => update(idx, { text: e.target.value })}
/>
</EditorField>
<EditorField label="URL" htmlFor={`task-card-source-url-${idx}`}>
<Input
id={`task-card-source-url-${idx}`}
type="url"
value={src.url}
placeholder="e.g. https://example.com/runbook"
onChange={(e) => update(idx, { url: e.target.value })}
/>
</EditorField>
</div>
))}
<Button type="button" size="sm" onClick={addSource} className="self-start">
<Plus className="h-3.5 w-3.5" /> Add source
</Button>
</div>
);
}
Loading
Loading