From eb8908e4343a34aa8c7f46bb3dd0687193567311 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Mon, 13 Apr 2026 14:45:17 +0200 Subject: [PATCH] feat: tracker config UI with visual AND/OR filter builder Adds FilterBuilder, TrackerConfig, TrackerList components and routes the /projects/:projectId/trackers/new path to TrackerConfig. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 2 + src/components/trackers/FilterBuilder.tsx | 182 +++++++++++++++++++++ src/components/trackers/TrackerConfig.tsx | 190 ++++++++++++++++++++++ src/components/trackers/TrackerList.tsx | 112 +++++++++++++ 4 files changed, 486 insertions(+) create mode 100644 src/components/trackers/FilterBuilder.tsx create mode 100644 src/components/trackers/TrackerConfig.tsx create mode 100644 src/components/trackers/TrackerList.tsx diff --git a/src/App.tsx b/src/App.tsx index df1dcd9..d1f0cfb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import AppLayout from "./components/layout/AppLayout"; import ProjectForm from "./components/projects/ProjectForm"; import ProjectDashboard from "./components/projects/ProjectDashboard"; import SettingsPage from "./components/settings/SettingsPage"; +import TrackerConfig from "./components/trackers/TrackerConfig"; function EmptyState() { return ( @@ -21,6 +22,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/trackers/FilterBuilder.tsx b/src/components/trackers/FilterBuilder.tsx new file mode 100644 index 0000000..45ad64f --- /dev/null +++ b/src/components/trackers/FilterBuilder.tsx @@ -0,0 +1,182 @@ +import type { FilterGroup, Filter, TrackerField } from "../../lib/types"; + +const OPERATORS = ["In", "NotIn", "Equals", "NotEquals"] as const; + +interface Props { + groups: FilterGroup[]; + onChange: (groups: FilterGroup[]) => void; + availableFields: TrackerField[]; +} + +export default function FilterBuilder({ groups, onChange, availableFields }: Props) { + function updateGroup(groupIndex: number, group: FilterGroup) { + const next = groups.map((g, i) => (i === groupIndex ? group : g)); + onChange(next); + } + + function removeGroup(groupIndex: number) { + onChange(groups.filter((_, i) => i !== groupIndex)); + } + + function addGroup() { + onChange([...groups, { conditions: [{ field: "", operator: "In", value: [] }] }]); + } + + function updateCondition(groupIndex: number, condIndex: number, cond: Filter) { + const group = groups[groupIndex]; + const conditions = group.conditions.map((c, i) => (i === condIndex ? cond : c)); + updateGroup(groupIndex, { conditions }); + } + + function removeCondition(groupIndex: number, condIndex: number) { + const group = groups[groupIndex]; + const conditions = group.conditions.filter((_, i) => i !== condIndex); + updateGroup(groupIndex, { conditions }); + } + + function addCondition(groupIndex: number) { + const group = groups[groupIndex]; + updateGroup(groupIndex, { + conditions: [...group.conditions, { field: "", operator: "In", value: [] }], + }); + } + + function toggleValue(groupIndex: number, condIndex: number, val: string) { + const cond = groups[groupIndex].conditions[condIndex]; + const next = cond.value.includes(val) + ? cond.value.filter((v) => v !== val) + : [...cond.value, val]; + updateCondition(groupIndex, condIndex, { ...cond, value: next }); + } + + return ( +
+ {groups.map((group, gi) => ( +
+ {gi > 0 && ( +
+
+ AND +
+
+ )} +
+
+ Group {gi + 1} + +
+ +
+ {group.conditions.map((cond, ci) => { + const fieldDef = availableFields.find((f) => f.label === cond.field); + return ( +
+ {ci > 0 && ( +
+
+ OR +
+
+ )} +
+
+ {/* Field dropdown */} + + + {/* Operator dropdown */} + + + {/* Remove condition */} + +
+ + {/* Value pills */} + {fieldDef && fieldDef.values.length > 0 && ( +
+ {fieldDef.values.map((v) => { + const selected = cond.value.includes(String(v.id)); + return ( + + ); + })} +
+ )} +
+
+ ); + })} +
+ + +
+
+ ))} + + +
+ ); +} diff --git a/src/components/trackers/TrackerConfig.tsx b/src/components/trackers/TrackerConfig.tsx new file mode 100644 index 0000000..8b05c15 --- /dev/null +++ b/src/components/trackers/TrackerConfig.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { addTracker, getTrackerFields } from "../../lib/api"; +import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types"; +import FilterBuilder from "./FilterBuilder"; + +export default function TrackerConfig() { + const { projectId } = useParams<{ projectId: string }>(); + const navigate = useNavigate(); + + const [trackerId, setTrackerId] = useState(""); + const [trackerLabel, setTrackerLabel] = useState(""); + const [pollingInterval, setPollingInterval] = useState(10); + const [fields, setFields] = useState([]); + const [fieldsLoaded, setFieldsLoaded] = useState(false); + const [fieldsLoading, setFieldsLoading] = useState(false); + const [filters, setFilters] = useState([]); + const [analystCommand, setAnalystCommand] = useState("claude"); + const [developerCommand, setDeveloperCommand] = useState("claude"); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleLoadFields() { + if (!trackerId) return; + setFieldsLoading(true); + setError(null); + try { + const result = await getTrackerFields(Number(trackerId)); + setFields(result); + setFieldsLoaded(true); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setFieldsLoading(false); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!projectId || trackerId === "") return; + setError(null); + setLoading(true); + + const agentConfig: AgentConfig = { + analyst_command: analystCommand, + analyst_args: [], + developer_command: developerCommand, + developer_args: [], + }; + + try { + await addTracker(projectId, Number(trackerId), trackerLabel, pollingInterval, agentConfig, filters); + navigate(`/projects/${projectId}`); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + } + + return ( +
+

Add tracker

+ +
+ {/* Basic fields */} +
+
+ +
+ { + setTrackerId(e.target.value === "" ? "" : Number(e.target.value)); + setFieldsLoaded(false); + setFields([]); + }} + required + min={1} + className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="e.g. 42" + /> + +
+
+ +
+ + setTrackerLabel(e.target.value)} + required + placeholder="e.g. Bugs" + className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + setPollingInterval(Number(e.target.value))} + required + min={1} + className="w-40 border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + {/* Filter builder */} + {fieldsLoaded && ( +
+

Filters

+ +
+ )} + + {/* Agent config */} +
+

Agent configuration

+
+ + setAnalystCommand(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setDeveloperCommand(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/components/trackers/TrackerList.tsx b/src/components/trackers/TrackerList.tsx new file mode 100644 index 0000000..fd03506 --- /dev/null +++ b/src/components/trackers/TrackerList.tsx @@ -0,0 +1,112 @@ +import { Link } from "react-router-dom"; +import { manualPoll, updateTracker, removeTracker } from "../../lib/api"; +import type { WatchedTracker } from "../../lib/types"; + +interface Props { + trackers: WatchedTracker[]; + projectId: string; + onRefresh: () => void; +} + +export default function TrackerList({ trackers, projectId, onRefresh }: Props) { + async function handlePollNow(tracker: WatchedTracker) { + try { + await manualPoll(tracker.id); + onRefresh(); + } catch (err) { + console.error("Poll failed:", err); + } + } + + async function handleToggleEnabled(tracker: WatchedTracker) { + try { + await updateTracker( + tracker.id, + tracker.polling_interval, + tracker.agent_config, + tracker.filters, + !tracker.enabled + ); + onRefresh(); + } catch (err) { + console.error("Update failed:", err); + } + } + + async function handleRemove(tracker: WatchedTracker) { + if (!window.confirm(`Remove tracker "${tracker.tracker_label}"?`)) return; + try { + await removeTracker(tracker.id); + onRefresh(); + } catch (err) { + console.error("Remove failed:", err); + } + } + + return ( +
+ {trackers.length === 0 && ( +
No trackers configured.
+ )} + + {trackers.map((tracker) => ( +
+
+
+ {tracker.tracker_label} + #{tracker.tracker_id} + + {tracker.enabled ? "Active" : "Paused"} + +
+
+ {tracker.last_polled_at + ? `Last poll: ${new Date(tracker.last_polled_at).toLocaleString()}` + : "Never polled"} +
+
+ +
+ + + +
+
+ ))} + + + Add tracker + +
+ ); +}