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/`)
|
### Core Components (`src/components/`)
|
||||||
| Component | Purpose |
|
| 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 |
|
| `InitiativeContent` | Content tab with page tree + editor |
|
||||||
| `StatusBadge` | Colored status indicator |
|
| `StatusBadge` | Colored status indicator |
|
||||||
| `TaskRow` | Task list item with status, priority, category |
|
| `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 |
|
| listInitiatives | query | Filter by status |
|
||||||
| getInitiative | query | With projects array |
|
| getInitiative | query | With projects array |
|
||||||
| updateInitiative | mutation | Name, status |
|
| updateInitiative | mutation | Name, status |
|
||||||
| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode |
|
| updateInitiativeConfig | mutation | mergeRequiresApproval, executionMode, branch |
|
||||||
|
|
||||||
### Phases
|
### Phases
|
||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import { ProjectPicker } from "./ProjectPicker";
|
import { ProjectPicker } from "./ProjectPicker";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
@@ -14,6 +15,7 @@ export interface InitiativeHeaderProps {
|
|||||||
status: string;
|
status: string;
|
||||||
executionMode?: string;
|
executionMode?: string;
|
||||||
branch?: string | null;
|
branch?: string | null;
|
||||||
|
branchLocked?: boolean;
|
||||||
};
|
};
|
||||||
projects?: Array<{ id: string; name: string; url: string }>;
|
projects?: Array<{ id: string; name: string; url: string }>;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -24,12 +26,17 @@ export function InitiativeHeader({
|
|||||||
projects,
|
projects,
|
||||||
onBack,
|
onBack,
|
||||||
}: InitiativeHeaderProps) {
|
}: InitiativeHeaderProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editingProjects, setEditingProjects] = useState(false);
|
||||||
const [editIds, setEditIds] = useState<string[]>([]);
|
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: () => {
|
onSuccess: () => {
|
||||||
setEditing(false);
|
setEditingProjects(false);
|
||||||
|
utils.getInitiative.invalidate({ id: initiative.id });
|
||||||
toast.success("Projects updated");
|
toast.success("Projects updated");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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) ?? []);
|
setEditIds(projects?.map((p) => p.id) ?? []);
|
||||||
setEditing(true);
|
setEditingProjects(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveProjects() {
|
function saveProjects() {
|
||||||
@@ -47,12 +63,33 @@ export function InitiativeHeader({
|
|||||||
toast.error("At least one project is required");
|
toast.error("At least one project is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateMutation.mutate({
|
projectMutation.mutate({
|
||||||
initiativeId: initiative.id,
|
initiativeId: initiative.id,
|
||||||
projectIds: editIds,
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -65,22 +102,63 @@ export function InitiativeHeader({
|
|||||||
{initiative.executionMode && (
|
{initiative.executionMode && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={
|
className={`cursor-pointer select-none transition-colors ${
|
||||||
initiative.executionMode === "yolo"
|
initiative.executionMode === "yolo"
|
||||||
? "border-orange-300 text-orange-700 text-[10px]"
|
? "border-orange-300 text-orange-700 text-[10px] hover:bg-orange-50"
|
||||||
: "border-blue-300 text-blue-700 text-[10px]"
|
: "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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{initiative.branch && (
|
{!editingBranch && initiative.branch && (
|
||||||
<Badge variant="outline" className="gap-1 text-[10px] font-mono">
|
<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" />
|
<GitBranch className="h-3 w-3" />
|
||||||
{initiative.branch}
|
{initiative.branch}
|
||||||
</Badge>
|
</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) => (
|
{projects.map((p) => (
|
||||||
<Badge key={p.id} variant="outline" className="text-xs font-normal">
|
<Badge key={p.id} variant="outline" className="text-xs font-normal">
|
||||||
@@ -91,40 +169,40 @@ export function InitiativeHeader({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6"
|
className="h-6 w-6"
|
||||||
onClick={startEditing}
|
onClick={startEditingProjects}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!editing && (!projects || projects.length === 0) && (
|
{!editingProjects && (!projects || projects.length === 0) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
onClick={startEditing}
|
onClick={startEditingProjects}
|
||||||
>
|
>
|
||||||
+ Add projects
|
+ Add projects
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{editing && (
|
{editingProjects && (
|
||||||
<div className="ml-11 max-w-sm space-y-2">
|
<div className="ml-11 max-w-sm space-y-2">
|
||||||
<ProjectPicker value={editIds} onChange={setEditIds} />
|
<ProjectPicker value={editIds} onChange={setEditIds} />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={saveProjects}
|
onClick={saveProjects}
|
||||||
disabled={editIds.length === 0 || updateMutation.isPending}
|
disabled={editIds.length === 0 || projectMutation.isPending}
|
||||||
>
|
>
|
||||||
<Check className="mr-1 h-3 w-3" />
|
<Check className="mr-1 h-3 w-3" />
|
||||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
{projectMutation.isPending ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setEditing(false)}
|
onClick={() => setEditingProjects(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ function InitiativeDetailPage() {
|
|||||||
status: initiative.status,
|
status: initiative.status,
|
||||||
executionMode: (initiative as any).executionMode as string | undefined,
|
executionMode: (initiative as any).executionMode as string | undefined,
|
||||||
branch: (initiative as any).branch as string | null | 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;
|
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import { requireInitiativeRepository, requireProjectRepository } from './_helpers.js';
|
import { requireInitiativeRepository, requireProjectRepository, requireTaskRepository } from './_helpers.js';
|
||||||
|
|
||||||
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||||
return {
|
return {
|
||||||
@@ -87,7 +87,13 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
projects = fullProjects.map((p) => ({ id: p.id, name: p.name, url: p.url }));
|
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
|
updateInitiative: publicProcedure
|
||||||
@@ -107,6 +113,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
initiativeId: z.string().min(1),
|
initiativeId: z.string().min(1),
|
||||||
mergeRequiresApproval: z.boolean().optional(),
|
mergeRequiresApproval: z.boolean().optional(),
|
||||||
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
||||||
|
branch: z.string().nullable().optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const repo = requireInitiativeRepository(ctx);
|
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);
|
return repo.update(initiativeId, data);
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user