feat: Make initiative branch and execution mode editable from header
- Execution mode badge toggles between YOLO/REVIEW on click - Branch badge opens inline editor (input + save/cancel) - Branch editing locked once any task has left pending status - Server-side guard rejects branch changes after work has started - getInitiative returns branchLocked flag - updateInitiativeConfig now accepts optional branch field
This commit is contained in:
@@ -39,7 +39,7 @@ The initiative detail page has three tabs managed via local state (not URL param
|
||||
### Core Components (`src/components/`)
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `InitiativeHeader` | Initiative name, project badges, merge config |
|
||||
| `InitiativeHeader` | Initiative name, project badges, inline-editable execution mode & branch |
|
||||
| `InitiativeContent` | Content tab with page tree + editor |
|
||||
| `StatusBadge` | Colored status indicator |
|
||||
| `TaskRow` | Task list item with status, priority, category |
|
||||
|
||||
@@ -87,7 +87,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| listInitiatives | query | Filter by status |
|
||||
| getInitiative | query | With projects array |
|
||||
| updateInitiative | mutation | Name, status |
|
||||
| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode |
|
||||
| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode, branch |
|
||||
|
||||
### Phases
|
||||
| Procedure | Type | Description |
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, Pencil, Check, GitBranch } from "lucide-react";
|
||||
import { ChevronLeft, Pencil, Check, X, GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { ProjectPicker } from "./ProjectPicker";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
@@ -14,6 +15,7 @@ export interface InitiativeHeaderProps {
|
||||
status: string;
|
||||
executionMode?: string;
|
||||
branch?: string | null;
|
||||
branchLocked?: boolean;
|
||||
};
|
||||
projects?: Array<{ id: string; name: string; url: string }>;
|
||||
onBack: () => void;
|
||||
@@ -24,12 +26,17 @@ export function InitiativeHeader({
|
||||
projects,
|
||||
onBack,
|
||||
}: InitiativeHeaderProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editingProjects, setEditingProjects] = useState(false);
|
||||
const [editIds, setEditIds] = useState<string[]>([]);
|
||||
const [editingBranch, setEditingBranch] = useState(false);
|
||||
const [branchValue, setBranchValue] = useState("");
|
||||
|
||||
const updateMutation = trpc.updateInitiativeProjects.useMutation({
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const projectMutation = trpc.updateInitiativeProjects.useMutation({
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
setEditingProjects(false);
|
||||
utils.getInitiative.invalidate({ id: initiative.id });
|
||||
toast.success("Projects updated");
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -37,9 +44,18 @@ export function InitiativeHeader({
|
||||
},
|
||||
});
|
||||
|
||||
function startEditing() {
|
||||
const configMutation = trpc.updateInitiativeConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.getInitiative.invalidate({ id: initiative.id });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function startEditingProjects() {
|
||||
setEditIds(projects?.map((p) => p.id) ?? []);
|
||||
setEditing(true);
|
||||
setEditingProjects(true);
|
||||
}
|
||||
|
||||
function saveProjects() {
|
||||
@@ -47,12 +63,33 @@ export function InitiativeHeader({
|
||||
toast.error("At least one project is required");
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate({
|
||||
projectMutation.mutate({
|
||||
initiativeId: initiative.id,
|
||||
projectIds: editIds,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExecutionMode() {
|
||||
const newMode = initiative.executionMode === "yolo" ? "review_per_phase" : "yolo";
|
||||
configMutation.mutate({
|
||||
initiativeId: initiative.id,
|
||||
executionMode: newMode as "yolo" | "review_per_phase",
|
||||
});
|
||||
}
|
||||
|
||||
function startEditingBranch() {
|
||||
setBranchValue(initiative.branch ?? "");
|
||||
setEditingBranch(true);
|
||||
}
|
||||
|
||||
function saveBranch() {
|
||||
configMutation.mutate({
|
||||
initiativeId: initiative.id,
|
||||
branch: branchValue.trim() || null,
|
||||
});
|
||||
setEditingBranch(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -65,22 +102,63 @@ export function InitiativeHeader({
|
||||
{initiative.executionMode && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
className={`cursor-pointer select-none transition-colors ${
|
||||
initiative.executionMode === "yolo"
|
||||
? "border-orange-300 text-orange-700 text-[10px]"
|
||||
: "border-blue-300 text-blue-700 text-[10px]"
|
||||
}
|
||||
? "border-orange-300 text-orange-700 text-[10px] hover:bg-orange-50"
|
||||
: "border-blue-300 text-blue-700 text-[10px] hover:bg-blue-50"
|
||||
}`}
|
||||
onClick={toggleExecutionMode}
|
||||
>
|
||||
{initiative.executionMode === "yolo" ? "YOLO" : "REVIEW"}
|
||||
{configMutation.isPending
|
||||
? "..."
|
||||
: initiative.executionMode === "yolo"
|
||||
? "YOLO"
|
||||
: "REVIEW"}
|
||||
</Badge>
|
||||
)}
|
||||
{initiative.branch && (
|
||||
<Badge variant="outline" className="gap-1 text-[10px] font-mono">
|
||||
{!editingBranch && initiative.branch && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`gap-1 text-[10px] font-mono transition-colors ${
|
||||
initiative.branchLocked ? "" : "cursor-pointer hover:bg-muted"
|
||||
}`}
|
||||
onClick={initiative.branchLocked ? undefined : startEditingBranch}
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{initiative.branch}
|
||||
</Badge>
|
||||
)}
|
||||
{!editing && projects && projects.length > 0 && (
|
||||
{!editingBranch && !initiative.branch && !initiative.branchLocked && (
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={startEditingBranch}
|
||||
>
|
||||
+ branch
|
||||
</button>
|
||||
)}
|
||||
{editingBranch && (
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
value={branchValue}
|
||||
onChange={(e) => setBranchValue(e.target.value)}
|
||||
placeholder="cw/my-feature"
|
||||
className="h-6 w-40 text-xs font-mono"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveBranch();
|
||||
if (e.key === "Escape") setEditingBranch(false);
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={saveBranch}>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => setEditingBranch(false)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!editingProjects && projects && projects.length > 0 && (
|
||||
<>
|
||||
{projects.map((p) => (
|
||||
<Badge key={p.id} variant="outline" className="text-xs font-normal">
|
||||
@@ -91,40 +169,40 @@ export function InitiativeHeader({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={startEditing}
|
||||
onClick={startEditingProjects}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && (!projects || projects.length === 0) && (
|
||||
{!editingProjects && (!projects || projects.length === 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={startEditing}
|
||||
onClick={startEditingProjects}
|
||||
>
|
||||
+ Add projects
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
{editingProjects && (
|
||||
<div className="ml-11 max-w-sm space-y-2">
|
||||
<ProjectPicker value={editIds} onChange={setEditIds} />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveProjects}
|
||||
disabled={editIds.length === 0 || updateMutation.isPending}
|
||||
disabled={editIds.length === 0 || projectMutation.isPending}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
||||
{projectMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(false)}
|
||||
onClick={() => setEditingProjects(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -91,6 +91,7 @@ function InitiativeDetailPage() {
|
||||
status: initiative.status,
|
||||
executionMode: (initiative as any).executionMode as string | undefined,
|
||||
branch: (initiative as any).branch as string | null | undefined,
|
||||
branchLocked: (initiative as any).branchLocked as boolean | undefined,
|
||||
};
|
||||
|
||||
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import { requireInitiativeRepository, requireProjectRepository } from './_helpers.js';
|
||||
import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
|
||||
|
||||
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
@@ -87,7 +87,13 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
projects = fullProjects.map((p) => ({ id: p.id, name: p.name, url: p.url }));
|
||||
}
|
||||
|
||||
return { ...initiative, projects };
|
||||
let branchLocked = false;
|
||||
if (ctx.taskRepository) {
|
||||
const tasks = await ctx.taskRepository.findByInitiativeId(input.id);
|
||||
branchLocked = tasks.some((t) => t.status !== 'pending');
|
||||
}
|
||||
|
||||
return { ...initiative, projects, branchLocked };
|
||||
}),
|
||||
|
||||
updateInitiative: publicProcedure
|
||||
@@ -107,6 +113,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
initiativeId: z.string().min(1),
|
||||
mergeRequiresApproval: z.boolean().optional(),
|
||||
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
||||
branch: z.string().nullable().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const repo = requireInitiativeRepository(ctx);
|
||||
@@ -120,6 +127,18 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent branch changes once work has started
|
||||
if (data.branch !== undefined && ctx.taskRepository) {
|
||||
const tasks = await ctx.taskRepository.findByInitiativeId(initiativeId);
|
||||
const hasStarted = tasks.some((t) => t.status !== 'pending');
|
||||
if (hasStarted) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'Cannot change branch after work has started',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return repo.update(initiativeId, data);
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user