From ed653cf2c1bcf618f484af27d1705f4ae442ace7 Mon Sep 17 00:00:00 2001 From: Adam Gohain <68021524+akgohain@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:59:39 -0400 Subject: [PATCH] Add workflow timeline actor and event-type filters --- client/package.json | 3 +- .../__tests__/workflowTimelineFilters.test.js | 73 +++++++++++++++++++ .../components/workflow/WorkflowTimeline.js | 47 ++++++++++++ .../workflow/WorkflowTimelineFilters.js | 36 +++++++++ .../workflow/WorkflowTimelineFilterContext.js | 45 ++++++++++++ 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 client/src/__tests__/workflowTimelineFilters.test.js create mode 100644 client/src/components/workflow/WorkflowTimeline.js create mode 100644 client/src/components/workflow/WorkflowTimelineFilters.js create mode 100644 client/src/contexts/workflow/WorkflowTimelineFilterContext.js diff --git a/client/package.json b/client/package.json index 7a08018..b533384 100644 --- a/client/package.json +++ b/client/package.json @@ -29,7 +29,8 @@ "scripts": { "start": "react-scripts start", "build": "cross-env CI=false react-scripts build", - "electron": "electron ." + "electron": "electron .", + "test": "react-scripts test" }, "eslintConfig": { "extends": [ diff --git a/client/src/__tests__/workflowTimelineFilters.test.js b/client/src/__tests__/workflowTimelineFilters.test.js new file mode 100644 index 0000000..4810af5 --- /dev/null +++ b/client/src/__tests__/workflowTimelineFilters.test.js @@ -0,0 +1,73 @@ +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WorkflowTimeline from "../components/workflow/WorkflowTimeline"; +import { WorkflowTimelineFilterProvider } from "../contexts/workflow/WorkflowTimelineFilterContext"; + +const EVENTS = [ + { id: "1", actor: "user", type: "load_dataset", message: "Loaded A" }, + { id: "2", actor: "agent", type: "run_inference", message: "Ran model" }, + { id: "3", actor: "system", type: "save_result", message: "Persisted" }, + { id: "4", actor: "user", type: "save_result", message: "Manual save" }, +]; + +function renderTimeline(events = EVENTS) { + return render( + + + , + ); +} + +function expectVisibleTypes(types) { + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(types.length); + types.forEach((type, index) => { + expect(within(items[index]).getByText(type)).toBeTruthy(); + }); +} + +describe("workflow timeline filters", () => { + test("shows full timeline by default", () => { + renderTimeline(); + expectVisibleTypes([ + "load_dataset", + "run_inference", + "save_result", + "save_result", + ]); + }); + + test("applies actor and event type filters together", async () => { + const user = userEvent.setup(); + renderTimeline(); + + await user.selectOptions(screen.getByLabelText("Actor filter"), "user"); + + const eventTypeInput = screen.getByLabelText("Event type filter"); + await user.type(eventTypeInput, "save"); + + expectVisibleTypes(["save_result"]); + expect(screen.getByText("Manual save")).toBeTruthy(); + }); + + test("clear resets filters", async () => { + const user = userEvent.setup(); + renderTimeline(); + + await user.selectOptions(screen.getByLabelText("Actor filter"), "system"); + await user.type(screen.getByLabelText("Event type filter"), "save"); + + expectVisibleTypes(["save_result"]); + expect(screen.getByText("Persisted")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: "Clear filters" })); + + expectVisibleTypes([ + "load_dataset", + "run_inference", + "save_result", + "save_result", + ]); + }); +}); diff --git a/client/src/components/workflow/WorkflowTimeline.js b/client/src/components/workflow/WorkflowTimeline.js new file mode 100644 index 0000000..e65609a --- /dev/null +++ b/client/src/components/workflow/WorkflowTimeline.js @@ -0,0 +1,47 @@ +import React, { useMemo } from "react"; +import { Typography } from "antd"; +import WorkflowTimelineFilters from "./WorkflowTimelineFilters"; +import { useWorkflowTimelineFilters } from "../../contexts/workflow/WorkflowTimelineFilterContext"; + +const { Text } = Typography; + +function WorkflowTimeline({ events = [] }) { + const { filters } = useWorkflowTimelineFilters(); + + const visibleEvents = useMemo(() => { + const normalizedType = filters.eventType.trim().toLowerCase(); + + return events.filter((event) => { + const actorMatches = + filters.actor === "all" || event.actor === filters.actor; + if (!actorMatches) return false; + if (!normalizedType) return true; + return (event.type || "").toLowerCase().includes(normalizedType); + }); + }, [events, filters.actor, filters.eventType]); + + return ( +
+ + {visibleEvents.length === 0 ? ( +
+ No timeline events +
+ ) : ( + + )} +
+ ); +} + +export default WorkflowTimeline; diff --git a/client/src/components/workflow/WorkflowTimelineFilters.js b/client/src/components/workflow/WorkflowTimelineFilters.js new file mode 100644 index 0000000..f0ff807 --- /dev/null +++ b/client/src/components/workflow/WorkflowTimelineFilters.js @@ -0,0 +1,36 @@ +import React from "react"; +import { Button, Input, Space } from "antd"; +import { useWorkflowTimelineFilters } from "../../contexts/workflow/WorkflowTimelineFilterContext"; + +function WorkflowTimelineFilters() { + const { filters, setActorFilter, setEventTypeFilter, clearFilters } = + useWorkflowTimelineFilters(); + + return ( + + + + setEventTypeFilter(event.target.value)} + allowClear + style={{ minWidth: 220 }} + /> + + + ); +} + +export default WorkflowTimelineFilters; diff --git a/client/src/contexts/workflow/WorkflowTimelineFilterContext.js b/client/src/contexts/workflow/WorkflowTimelineFilterContext.js new file mode 100644 index 0000000..b21c3cf --- /dev/null +++ b/client/src/contexts/workflow/WorkflowTimelineFilterContext.js @@ -0,0 +1,45 @@ +import React, { createContext, useContext, useMemo, useState } from "react"; + +const DEFAULT_FILTERS = { + actor: "all", + eventType: "", +}; + +const WorkflowTimelineFilterContext = createContext({ + filters: DEFAULT_FILTERS, + setActorFilter: () => {}, + setEventTypeFilter: () => {}, + clearFilters: () => {}, +}); + +export function WorkflowTimelineFilterProvider({ children }) { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + + const value = useMemo( + () => ({ + filters, + setActorFilter: (actor) => { + setFilters((current) => ({ ...current, actor })); + }, + setEventTypeFilter: (eventType) => { + setFilters((current) => ({ ...current, eventType })); + }, + clearFilters: () => { + setFilters(DEFAULT_FILTERS); + }, + }), + [filters], + ); + + return ( + + {children} + + ); +} + +export function useWorkflowTimelineFilters() { + return useContext(WorkflowTimelineFilterContext); +} + +export { DEFAULT_FILTERS };