Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
73 changes: 73 additions & 0 deletions client/src/__tests__/workflowTimelineFilters.test.js
Original file line number Diff line number Diff line change
@@ -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(
<WorkflowTimelineFilterProvider>
<WorkflowTimeline events={events} />
</WorkflowTimelineFilterProvider>,
);
}

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",
]);
});
});
47 changes: 47 additions & 0 deletions client/src/components/workflow/WorkflowTimeline.js
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<WorkflowTimelineFilters />
{visibleEvents.length === 0 ? (
<div style={{ marginTop: 12 }}>
<Text type="secondary">No timeline events</Text>
</div>
) : (
<ul style={{ marginTop: 12, paddingLeft: 20 }}>
{visibleEvents.map((event) => (
<li key={event.id}>
<Text code>{event.actor}</Text>
<Text style={{ marginLeft: 8 }}>{event.type}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>
{event.message}
</Text>
</li>
))}
</ul>
)}
</div>
);
}

export default WorkflowTimeline;
36 changes: 36 additions & 0 deletions client/src/components/workflow/WorkflowTimelineFilters.js
Original file line number Diff line number Diff line change
@@ -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 (
<Space wrap size={8} style={{ width: "100%" }}>
<label htmlFor="workflow-actor-filter">Actor</label>
<select
id="workflow-actor-filter"
aria-label="Actor filter"
value={filters.actor}
onChange={(event) => setActorFilter(event.target.value)}
>
<option value="all">All actors</option>
<option value="user">User</option>
<option value="agent">Agent</option>
<option value="system">System</option>
</select>
<Input
aria-label="Event type filter"
placeholder="Filter event type"
value={filters.eventType}
onChange={(event) => setEventTypeFilter(event.target.value)}
allowClear
style={{ minWidth: 220 }}
/>
<Button onClick={clearFilters}>Clear filters</Button>
</Space>
);
}

export default WorkflowTimelineFilters;
45 changes: 45 additions & 0 deletions client/src/contexts/workflow/WorkflowTimelineFilterContext.js
Original file line number Diff line number Diff line change
@@ -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 (
<WorkflowTimelineFilterContext.Provider value={value}>
{children}
</WorkflowTimelineFilterContext.Provider>
);
}

export function useWorkflowTimelineFilters() {
return useContext(WorkflowTimelineFilterContext);
}

export { DEFAULT_FILTERS };
Loading