feat: Re-add initiative branch field and add projects settings page
Allow users to specify a custom branch when creating initiatives (auto-generated if left blank). Add updateProject tRPC procedure and /settings/projects page with inline-editable defaultBranch.
This commit is contained in:
@@ -83,7 +83,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
### Initiatives
|
### Initiatives
|
||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
|-----------|------|-------------|
|
|-----------|------|-------------|
|
||||||
| createInitiative | mutation | Create with optional projectIds, auto-creates root page |
|
| createInitiative | mutation | Create with optional branch/projectIds, auto-creates root page |
|
||||||
| 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 |
|
||||||
@@ -143,6 +143,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| registerProject | mutation | Clone git repo, create record |
|
| registerProject | mutation | Clone git repo, create record |
|
||||||
| listProjects | query | All projects |
|
| listProjects | query | All projects |
|
||||||
| getProject | query | Single project |
|
| getProject | query | Single project |
|
||||||
|
| updateProject | mutation | Update project settings (defaultBranch) |
|
||||||
| deleteProject | mutation | Delete clone and record |
|
| deleteProject | mutation | Delete clone and record |
|
||||||
| getInitiativeProjects | query | Projects for initiative |
|
| getInitiativeProjects | query | Projects for initiative |
|
||||||
| updateInitiativeProjects | mutation | Sync junction table |
|
| updateInitiativeProjects | mutation | Sync junction table |
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function CreateInitiativeDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: CreateInitiativeDialogProps) {
|
}: CreateInitiativeDialogProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [branch, setBranch] = useState("");
|
||||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||||
const [executionMode, setExecutionMode] = useState<"yolo" | "review_per_phase">("review_per_phase");
|
const [executionMode, setExecutionMode] = useState<"yolo" | "review_per_phase">("review_per_phase");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -70,6 +71,7 @@ export function CreateInitiativeDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setName("");
|
setName("");
|
||||||
|
setBranch("");
|
||||||
setProjectIds([]);
|
setProjectIds([]);
|
||||||
setExecutionMode("review_per_phase");
|
setExecutionMode("review_per_phase");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -81,6 +83,7 @@ export function CreateInitiativeDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
branch: branch.trim() || null,
|
||||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||||
executionMode,
|
executionMode,
|
||||||
});
|
});
|
||||||
@@ -108,6 +111,20 @@ export function CreateInitiativeDialog({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="initiative-branch">
|
||||||
|
Branch{" "}
|
||||||
|
<span className="text-muted-foreground font-normal">
|
||||||
|
(optional — auto-generated if blank)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="initiative-branch"
|
||||||
|
placeholder="e.g. cw/my-feature"
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Execution Mode</Label>
|
<Label>Execution Mode</Label>
|
||||||
<Select value={executionMode} onValueChange={(v) => setExecutionMode(v as "yolo" | "review_per_phase")}>
|
<Select value={executionMode} onValueChange={(v) => setExecutionMode(v as "yolo" | "review_per_phase")}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Route as AgentsRouteImport } from './routes/agents'
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
||||||
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
|
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
|
||||||
|
import { Route as SettingsProjectsRouteImport } from './routes/settings/projects'
|
||||||
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
|
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
|
||||||
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
|
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
|
||||||
|
|
||||||
@@ -48,6 +49,11 @@ const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
|
|||||||
path: '/initiatives/',
|
path: '/initiatives/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
|
||||||
|
id: '/projects',
|
||||||
|
path: '/projects',
|
||||||
|
getParentRoute: () => SettingsRoute,
|
||||||
|
} as any)
|
||||||
const SettingsHealthRoute = SettingsHealthRouteImport.update({
|
const SettingsHealthRoute = SettingsHealthRouteImport.update({
|
||||||
id: '/health',
|
id: '/health',
|
||||||
path: '/health',
|
path: '/health',
|
||||||
@@ -66,6 +72,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings': typeof SettingsRouteWithChildren
|
'/settings': typeof SettingsRouteWithChildren
|
||||||
'/initiatives/$id': typeof InitiativesIdRoute
|
'/initiatives/$id': typeof InitiativesIdRoute
|
||||||
'/settings/health': typeof SettingsHealthRoute
|
'/settings/health': typeof SettingsHealthRoute
|
||||||
|
'/settings/projects': typeof SettingsProjectsRoute
|
||||||
'/initiatives/': typeof InitiativesIndexRoute
|
'/initiatives/': typeof InitiativesIndexRoute
|
||||||
'/settings/': typeof SettingsIndexRoute
|
'/settings/': typeof SettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -75,6 +82,7 @@ export interface FileRoutesByTo {
|
|||||||
'/inbox': typeof InboxRoute
|
'/inbox': typeof InboxRoute
|
||||||
'/initiatives/$id': typeof InitiativesIdRoute
|
'/initiatives/$id': typeof InitiativesIdRoute
|
||||||
'/settings/health': typeof SettingsHealthRoute
|
'/settings/health': typeof SettingsHealthRoute
|
||||||
|
'/settings/projects': typeof SettingsProjectsRoute
|
||||||
'/initiatives': typeof InitiativesIndexRoute
|
'/initiatives': typeof InitiativesIndexRoute
|
||||||
'/settings': typeof SettingsIndexRoute
|
'/settings': typeof SettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -86,6 +94,7 @@ export interface FileRoutesById {
|
|||||||
'/settings': typeof SettingsRouteWithChildren
|
'/settings': typeof SettingsRouteWithChildren
|
||||||
'/initiatives/$id': typeof InitiativesIdRoute
|
'/initiatives/$id': typeof InitiativesIdRoute
|
||||||
'/settings/health': typeof SettingsHealthRoute
|
'/settings/health': typeof SettingsHealthRoute
|
||||||
|
'/settings/projects': typeof SettingsProjectsRoute
|
||||||
'/initiatives/': typeof InitiativesIndexRoute
|
'/initiatives/': typeof InitiativesIndexRoute
|
||||||
'/settings/': typeof SettingsIndexRoute
|
'/settings/': typeof SettingsIndexRoute
|
||||||
}
|
}
|
||||||
@@ -98,6 +107,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/initiatives/$id'
|
| '/initiatives/$id'
|
||||||
| '/settings/health'
|
| '/settings/health'
|
||||||
|
| '/settings/projects'
|
||||||
| '/initiatives/'
|
| '/initiatives/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -107,6 +117,7 @@ export interface FileRouteTypes {
|
|||||||
| '/inbox'
|
| '/inbox'
|
||||||
| '/initiatives/$id'
|
| '/initiatives/$id'
|
||||||
| '/settings/health'
|
| '/settings/health'
|
||||||
|
| '/settings/projects'
|
||||||
| '/initiatives'
|
| '/initiatives'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
id:
|
id:
|
||||||
@@ -117,6 +128,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings'
|
| '/settings'
|
||||||
| '/initiatives/$id'
|
| '/initiatives/$id'
|
||||||
| '/settings/health'
|
| '/settings/health'
|
||||||
|
| '/settings/projects'
|
||||||
| '/initiatives/'
|
| '/initiatives/'
|
||||||
| '/settings/'
|
| '/settings/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -174,6 +186,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof InitiativesIndexRouteImport
|
preLoaderRoute: typeof InitiativesIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/settings/projects': {
|
||||||
|
id: '/settings/projects'
|
||||||
|
path: '/projects'
|
||||||
|
fullPath: '/settings/projects'
|
||||||
|
preLoaderRoute: typeof SettingsProjectsRouteImport
|
||||||
|
parentRoute: typeof SettingsRoute
|
||||||
|
}
|
||||||
'/settings/health': {
|
'/settings/health': {
|
||||||
id: '/settings/health'
|
id: '/settings/health'
|
||||||
path: '/health'
|
path: '/health'
|
||||||
@@ -193,11 +212,13 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface SettingsRouteChildren {
|
interface SettingsRouteChildren {
|
||||||
SettingsHealthRoute: typeof SettingsHealthRoute
|
SettingsHealthRoute: typeof SettingsHealthRoute
|
||||||
|
SettingsProjectsRoute: typeof SettingsProjectsRoute
|
||||||
SettingsIndexRoute: typeof SettingsIndexRoute
|
SettingsIndexRoute: typeof SettingsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsRouteChildren: SettingsRouteChildren = {
|
const SettingsRouteChildren: SettingsRouteChildren = {
|
||||||
SettingsHealthRoute: SettingsHealthRoute,
|
SettingsHealthRoute: SettingsHealthRoute,
|
||||||
|
SettingsProjectsRoute: SettingsProjectsRoute,
|
||||||
SettingsIndexRoute: SettingsIndexRoute,
|
SettingsIndexRoute: SettingsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const Route = createFileRoute('/settings')({
|
|||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{ label: 'Health Check', to: '/settings/health' },
|
{ label: 'Health Check', to: '/settings/health' },
|
||||||
|
{ label: 'Projects', to: '/settings/projects' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function SettingsLayout() {
|
function SettingsLayout() {
|
||||||
|
|||||||
179
packages/web/src/routes/settings/projects.tsx
Normal file
179
packages/web/src/routes/settings/projects.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Skeleton } from '@/components/Skeleton'
|
||||||
|
import { RegisterProjectDialog } from '@/components/RegisterProjectDialog'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/settings/projects')({
|
||||||
|
component: ProjectsSettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProjectsSettingsPage() {
|
||||||
|
const [registerOpen, setRegisterOpen] = useState(false)
|
||||||
|
|
||||||
|
const projectsQuery = trpc.listProjects.useQuery()
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const deleteMutation = trpc.deleteProject.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.listProjects.invalidate()
|
||||||
|
toast.success('Project deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to delete project: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: projects, isLoading } = projectsQuery
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-9 w-36" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setRegisterOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Register Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!projects || projects.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8">
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
No projects registered yet.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRegisterOpen(true)}
|
||||||
|
>
|
||||||
|
Register Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
projects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
onDelete={() => {
|
||||||
|
if (window.confirm(`Delete project "${project.name}"? This will also remove the cloned repository.`)) {
|
||||||
|
deleteMutation.mutate({ id: project.id })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RegisterProjectDialog
|
||||||
|
open={registerOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setRegisterOpen(open)
|
||||||
|
if (!open) void utils.listProjects.invalidate()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
project: { id: string; name: string; url: string; defaultBranch: string }
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [editValue, setEditValue] = useState(project.defaultBranch)
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const updateMutation = trpc.updateProject.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.listProjects.invalidate()
|
||||||
|
setEditing(false)
|
||||||
|
toast.success('Default branch updated')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to update: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
const trimmed = editValue.trim()
|
||||||
|
if (!trimmed || trimmed === project.defaultBranch) {
|
||||||
|
setEditing(false)
|
||||||
|
setEditValue(project.defaultBranch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateMutation.mutate({ id: project.id, defaultBranch: trimmed })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-semibold">{project.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{project.url}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">Default branch:</span>
|
||||||
|
{editing ? (
|
||||||
|
<Input
|
||||||
|
className="h-6 w-40 text-xs"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') saveEdit()
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditing(false)
|
||||||
|
setEditValue(project.defaultBranch)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={saveEdit}
|
||||||
|
autoFocus
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 rounded px-1 py-0.5 font-mono hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
setEditValue(project.defaultBranch)
|
||||||
|
setEditing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.defaultBranch}
|
||||||
|
<Pencil className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
createInitiative: publicProcedure
|
createInitiative: publicProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
branch: z.string().nullable().optional(),
|
||||||
projectIds: z.array(z.string().min(1)).min(1).optional(),
|
projectIds: z.array(z.string().min(1)).min(1).optional(),
|
||||||
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
|
||||||
}))
|
}))
|
||||||
@@ -35,6 +36,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
...(input.executionMode && { executionMode: input.executionMode }),
|
...(input.executionMode && { executionMode: input.executionMode }),
|
||||||
|
...(input.branch && { branch: input.branch }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (input.projectIds && input.projectIds.length > 0) {
|
if (input.projectIds && input.projectIds.length > 0) {
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateProject: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
defaultBranch: z.string().min(1).optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const repo = requireProjectRepository(ctx);
|
||||||
|
const { id, ...data } = input;
|
||||||
|
const existing = await repo.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: `Project '${id}' not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return repo.update(id, data);
|
||||||
|
}),
|
||||||
|
|
||||||
getInitiativeProjects: publicProcedure
|
getInitiativeProjects: publicProcedure
|
||||||
.input(z.object({ initiativeId: z.string().min(1) }))
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user