feat: Add project filter to listInitiatives
Add findByProjectId to InitiativeRepository using a subquery on the initiative_projects junction table. Extend the listInitiatives tRPC procedure to accept an optional projectId filter that composes with the existing status filter. Add a project dropdown to the initiatives page alongside the status filter.
This commit is contained in:
@@ -4,10 +4,10 @@
|
|||||||
* Implements InitiativeRepository interface using Drizzle ORM.
|
* Implements InitiativeRepository interface using Drizzle ORM.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { DrizzleDatabase } from '../../index.js';
|
import type { DrizzleDatabase } from '../../index.js';
|
||||||
import { agents, initiatives, type Initiative } from '../../schema.js';
|
import { agents, initiatives, initiativeProjects, type Initiative } from '../../schema.js';
|
||||||
import type {
|
import type {
|
||||||
InitiativeRepository,
|
InitiativeRepository,
|
||||||
CreateInitiativeData,
|
CreateInitiativeData,
|
||||||
@@ -59,6 +59,18 @@ export class DrizzleInitiativeRepository implements InitiativeRepository {
|
|||||||
.where(eq(initiatives.status, status));
|
.where(eq(initiatives.status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByProjectId(projectId: string): Promise<Initiative[]> {
|
||||||
|
const linkedIds = this.db
|
||||||
|
.select({ id: initiativeProjects.initiativeId })
|
||||||
|
.from(initiativeProjects)
|
||||||
|
.where(eq(initiativeProjects.projectId, projectId));
|
||||||
|
|
||||||
|
return this.db
|
||||||
|
.select()
|
||||||
|
.from(initiatives)
|
||||||
|
.where(inArray(initiatives.id, linkedIds));
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: string, data: UpdateInitiativeData): Promise<Initiative> {
|
async update(id: string, data: UpdateInitiativeData): Promise<Initiative> {
|
||||||
const [updated] = await this.db
|
const [updated] = await this.db
|
||||||
.update(initiatives)
|
.update(initiatives)
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export interface InitiativeRepository {
|
|||||||
*/
|
*/
|
||||||
update(id: string, data: UpdateInitiativeData): Promise<Initiative>;
|
update(id: string, data: UpdateInitiativeData): Promise<Initiative>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all initiatives linked to a specific project.
|
||||||
|
* Returns empty array if none exist.
|
||||||
|
*/
|
||||||
|
findByProjectId(projectId: string): Promise<Initiative[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an initiative.
|
* Delete an initiative.
|
||||||
* Throws if initiative not found.
|
* Throws if initiative not found.
|
||||||
|
|||||||
@@ -111,12 +111,22 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
listInitiatives: publicProcedure
|
listInitiatives: publicProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
status: z.enum(['active', 'completed', 'archived']).optional(),
|
status: z.enum(['active', 'completed', 'archived']).optional(),
|
||||||
|
projectId: z.string().min(1).optional(),
|
||||||
}).optional())
|
}).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const repo = requireInitiativeRepository(ctx);
|
const repo = requireInitiativeRepository(ctx);
|
||||||
const initiatives = input?.status
|
|
||||||
? await repo.findByStatus(input.status)
|
let initiatives;
|
||||||
: await repo.findAll();
|
if (input?.projectId) {
|
||||||
|
const all = await repo.findByProjectId(input.projectId);
|
||||||
|
initiatives = input.status
|
||||||
|
? all.filter(i => i.status === input.status)
|
||||||
|
: all;
|
||||||
|
} else {
|
||||||
|
initiatives = input?.status
|
||||||
|
? await repo.findByStatus(input.status)
|
||||||
|
: await repo.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch active architect agents once for all initiatives
|
// Fetch active architect agents once for all initiatives
|
||||||
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
|
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
|
||||||
|
|||||||
@@ -8,18 +8,27 @@ import { trpc } from "@/lib/trpc";
|
|||||||
|
|
||||||
interface InitiativeListProps {
|
interface InitiativeListProps {
|
||||||
statusFilter?: "all" | "active" | "completed" | "archived";
|
statusFilter?: "all" | "active" | "completed" | "archived";
|
||||||
|
projectFilter?: string;
|
||||||
onCreateNew: () => void;
|
onCreateNew: () => void;
|
||||||
onViewInitiative: (id: string) => void;
|
onViewInitiative: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InitiativeList({
|
export function InitiativeList({
|
||||||
statusFilter = "all",
|
statusFilter = "all",
|
||||||
|
projectFilter,
|
||||||
onCreateNew,
|
onCreateNew,
|
||||||
onViewInitiative,
|
onViewInitiative,
|
||||||
}: InitiativeListProps) {
|
}: InitiativeListProps) {
|
||||||
const initiativesQuery = trpc.listInitiatives.useQuery(
|
const queryInput = (() => {
|
||||||
statusFilter === "all" ? undefined : { status: statusFilter },
|
const hasStatus = statusFilter !== "all";
|
||||||
);
|
if (!hasStatus && !projectFilter) return undefined;
|
||||||
|
return {
|
||||||
|
...(hasStatus && { status: statusFilter as "active" | "completed" | "archived" }),
|
||||||
|
...(projectFilter && { projectId: projectFilter }),
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const initiativesQuery = trpc.listInitiatives.useQuery(queryInput);
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (initiativesQuery.isLoading) {
|
if (initiativesQuery.isLoading) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { InitiativeList } from "@/components/InitiativeList";
|
import { InitiativeList } from "@/components/InitiativeList";
|
||||||
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
|
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
|
||||||
import { useLiveUpdates } from "@/hooks";
|
import { useLiveUpdates } from "@/hooks";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
|
||||||
export const Route = createFileRoute("/initiatives/")({
|
export const Route = createFileRoute("/initiatives/")({
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
@@ -23,7 +24,9 @@ const filterOptions: { value: StatusFilter; label: string }[] = [
|
|||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
const [projectFilter, setProjectFilter] = useState<string>("all");
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const projectsQuery = trpc.listProjects.useQuery();
|
||||||
|
|
||||||
// Single SSE stream for live updates
|
// Single SSE stream for live updates
|
||||||
useLiveUpdates([
|
useLiveUpdates([
|
||||||
@@ -42,6 +45,18 @@ function DashboardPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="font-display text-2xl font-semibold">Initiatives</h1>
|
<h1 className="font-display text-2xl font-semibold">Initiatives</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={projectFilter}
|
||||||
|
onChange={(e) => setProjectFilter(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<option value="all">All projects</option>
|
||||||
|
{(projectsQuery.data ?? []).map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -70,6 +85,7 @@ function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<InitiativeList
|
<InitiativeList
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
|
projectFilter={projectFilter === "all" ? undefined : projectFilter}
|
||||||
onCreateNew={() => setCreateDialogOpen(true)}
|
onCreateNew={() => setCreateDialogOpen(true)}
|
||||||
onViewInitiative={(id) =>
|
onViewInitiative={(id) =>
|
||||||
navigate({ to: "/initiatives/$id", params: { id } })
|
navigate({ to: "/initiatives/$id", params: { id } })
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
|-----------|------|-------------|
|
|-----------|------|-------------|
|
||||||
| createInitiative | mutation | Create with optional branch/projectIds/description, auto-creates root page (seeded with description); if description provided, auto-spawns refine agent |
|
| createInitiative | mutation | Create with optional branch/projectIds/description, auto-creates root page (seeded with description); if description provided, auto-spawns refine agent |
|
||||||
| listInitiatives | query | Filter by status; returns `activity` (state, activePhase, phase counts) computed from phases |
|
| listInitiatives | query | Filter by status and/or projectId; returns `activity` (state, activePhase, phase counts) computed from phases |
|
||||||
| getInitiative | query | With projects array |
|
| getInitiative | query | With projects array |
|
||||||
| updateInitiative | mutation | Name, status |
|
| updateInitiative | mutation | Name, status |
|
||||||
| deleteInitiative | mutation | Cascade delete initiative and all children |
|
| deleteInitiative | mutation | Cascade delete initiative and all children |
|
||||||
|
|||||||
Reference in New Issue
Block a user