@durablex/react-ui 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/dist/index.d.ts +1078 -0
- package/dist/index.js +6407 -0
- package/dist/index.js.map +1 -0
- package/package.json +86 -0
- package/src/components/AnimatedDurablexMark.tsx +35 -0
- package/src/components/AppStatusBadge.tsx +17 -0
- package/src/components/AppTag.tsx +17 -0
- package/src/components/AppsView.tsx +226 -0
- package/src/components/BulkReplayButton.tsx +52 -0
- package/src/components/CursorPager.tsx +50 -0
- package/src/components/DeliveriesSplit.tsx +187 -0
- package/src/components/DeliveryDetail.tsx +188 -0
- package/src/components/DurablexLogo.tsx +12 -0
- package/src/components/EndpointFormDialog.tsx +153 -0
- package/src/components/EndpointRow.tsx +172 -0
- package/src/components/EndpointsTab.tsx +83 -0
- package/src/components/EventsList.tsx +170 -0
- package/src/components/EventsView.tsx +24 -0
- package/src/components/Facts.tsx +14 -0
- package/src/components/FlowControlBadge.tsx +23 -0
- package/src/components/FlowControlSection.tsx +82 -0
- package/src/components/FlowSummary.tsx +47 -0
- package/src/components/FormField.tsx +10 -0
- package/src/components/GlyphBadge.tsx +41 -0
- package/src/components/JsonBlock.tsx +48 -0
- package/src/components/JsonEditor.tsx +91 -0
- package/src/components/LogList.tsx +45 -0
- package/src/components/Meta.tsx +31 -0
- package/src/components/OverviewView.tsx +39 -0
- package/src/components/PayloadTabs.tsx +70 -0
- package/src/components/ReceiverFormDialog.tsx +123 -0
- package/src/components/ReceiversTab.tsx +194 -0
- package/src/components/ReplayRunDialog.tsx +112 -0
- package/src/components/ResumeMark.tsx +38 -0
- package/src/components/RetryFromStepButton.tsx +44 -0
- package/src/components/RunCancelButton.tsx +23 -0
- package/src/components/RunControlHistory.tsx +71 -0
- package/src/components/RunInspector.test.tsx +78 -0
- package/src/components/RunInspector.tsx +297 -0
- package/src/components/RunInspectorActions.tsx +40 -0
- package/src/components/RunPauseButton.tsx +34 -0
- package/src/components/RunnerLiveBadge.tsx +11 -0
- package/src/components/RunsFilterBar.tsx +180 -0
- package/src/components/RunsTable.tsx +110 -0
- package/src/components/RunsTableHead.tsx +19 -0
- package/src/components/RunsTableLoader.tsx +10 -0
- package/src/components/RunsTablePlaceholder.tsx +19 -0
- package/src/components/RunsTableRow.tsx +103 -0
- package/src/components/RunsView.test.tsx +46 -0
- package/src/components/RunsView.tsx +243 -0
- package/src/components/ScheduledBadge.tsx +15 -0
- package/src/components/SecretReveal.tsx +45 -0
- package/src/components/SectionHeader.tsx +10 -0
- package/src/components/StatTileGrid.tsx +71 -0
- package/src/components/StatsTiles.tsx +66 -0
- package/src/components/StatusBadge.tsx +50 -0
- package/src/components/StepFlow.tsx +105 -0
- package/src/components/StepGlyph.tsx +25 -0
- package/src/components/StepInspector.tsx +44 -0
- package/src/components/StepRow.tsx +69 -0
- package/src/components/StepTabsView.tsx +51 -0
- package/src/components/StepTimeline.tsx +87 -0
- package/src/components/TableStatusRows.tsx +54 -0
- package/src/components/TriggerEventDialog.tsx +180 -0
- package/src/components/TriggerEventResult.tsx +61 -0
- package/src/components/WebhookBadges.tsx +69 -0
- package/src/components/WebhookStatusBadge.tsx +25 -0
- package/src/components/WebhooksView.tsx +69 -0
- package/src/components/WorkflowDetail.tsx +149 -0
- package/src/components/WorkflowRunAction.tsx +46 -0
- package/src/components/WorkflowRunDialog.tsx +187 -0
- package/src/components/WorkflowsView.tsx +168 -0
- package/src/components/charts/ChartCard.tsx +19 -0
- package/src/components/charts/RunCharts.tsx +31 -0
- package/src/components/charts/RunLatencyChart.tsx +71 -0
- package/src/components/charts/RunsOverTimeChart.tsx +60 -0
- package/src/components/filters/AppFilter.tsx +65 -0
- package/src/components/filters/FilterDropdown.tsx +33 -0
- package/src/components/filters/FilterDropdownButton.tsx +31 -0
- package/src/components/filters/FilterDropdownItem.tsx +37 -0
- package/src/components/filters/TimeRangeFilter.tsx +43 -0
- package/src/components/filters/TimeZoneFilter.tsx +40 -0
- package/src/components/filters/use-click-outside.ts +18 -0
- package/src/components/filters-pager.test.tsx +94 -0
- package/src/components/marks-geometry.ts +10 -0
- package/src/components/replay-dialog.test.tsx +18 -0
- package/src/components/run-components.test.tsx +126 -0
- package/src/components/run-controls.test.tsx +97 -0
- package/src/hooks/use-confirm-action.ts +19 -0
- package/src/hooks/use-copy.ts +22 -0
- package/src/hooks/use-keyset-pager.ts +34 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/index.ts +165 -0
- package/src/lib/app-color.test.ts +32 -0
- package/src/lib/app-color.ts +8 -0
- package/src/lib/control-action.ts +36 -0
- package/src/lib/flow-control.ts +77 -0
- package/src/lib/format.test.ts +102 -0
- package/src/lib/format.ts +45 -0
- package/src/lib/json-highlight.test.ts +36 -0
- package/src/lib/json-highlight.ts +64 -0
- package/src/lib/run-filters.ts +8 -0
- package/src/lib/run-logs.test.ts +80 -0
- package/src/lib/run-logs.ts +34 -0
- package/src/lib/run-progress.test.ts +109 -0
- package/src/lib/run-progress.ts +44 -0
- package/src/lib/run-sort.test.ts +40 -0
- package/src/lib/run-sort.ts +19 -0
- package/src/lib/status-label.test.ts +35 -0
- package/src/lib/status-label.ts +13 -0
- package/src/lib/step-detail.test.ts +122 -0
- package/src/lib/step-detail.ts +35 -0
- package/src/lib/step-display.test.ts +19 -0
- package/src/lib/step-display.ts +13 -0
- package/src/lib/step-timeline.test.ts +89 -0
- package/src/lib/step-timeline.ts +50 -0
- package/src/lib/table.ts +2 -0
- package/src/lib/theme.ts +35 -0
- package/src/lib/time-range.ts +81 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/webhook-view.test.ts +176 -0
- package/src/lib/webhook-view.ts +113 -0
- package/src/lib/workflow-run.test.ts +55 -0
- package/src/lib/workflow-run.ts +45 -0
- package/src/shell/AppShell.tsx +34 -0
- package/src/shell/Sidebar.tsx +78 -0
- package/src/shell/Topbar.tsx +22 -0
- package/src/styles.css +2204 -0
- package/src/test-utils.tsx +130 -0
- package/src/ui/button.tsx +67 -0
- package/src/ui/chart.tsx +337 -0
- package/src/ui/dialog.tsx +145 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/resizable.tsx +40 -0
- package/src/ui/separator.tsx +28 -0
- package/src/ui/sheet.tsx +128 -0
- package/src/ui/sidebar.tsx +665 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/sonner.tsx +35 -0
- package/src/ui/table.tsx +87 -0
- package/src/ui/tooltip.tsx +51 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export function FilterDropdownItem({
|
|
4
|
+
active,
|
|
5
|
+
dot,
|
|
6
|
+
label,
|
|
7
|
+
right,
|
|
8
|
+
onClick,
|
|
9
|
+
}: {
|
|
10
|
+
active: boolean;
|
|
11
|
+
dot?: string | null;
|
|
12
|
+
label: string;
|
|
13
|
+
right?: string;
|
|
14
|
+
onClick: () => void;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
className="dd-item focusable"
|
|
20
|
+
data-on={active ? "1" : "0"}
|
|
21
|
+
onClick={onClick}
|
|
22
|
+
>
|
|
23
|
+
{dot !== undefined && (
|
|
24
|
+
<span
|
|
25
|
+
className="dd-dot"
|
|
26
|
+
style={{
|
|
27
|
+
background: dot || "transparent",
|
|
28
|
+
border: dot ? "0" : "1px solid var(--border)",
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
)}
|
|
32
|
+
<span className="dd-lbl">{label}</span>
|
|
33
|
+
{right != null && <span className="dd-rt">{right}</span>}
|
|
34
|
+
{active && <Check className="dd-check" />}
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { TIME_OPTIONS, timeLabel } from "../../lib/time-range";
|
|
2
|
+
import { FilterDropdown } from "./FilterDropdown";
|
|
3
|
+
import { FilterDropdownButton } from "./FilterDropdownButton";
|
|
4
|
+
import { FilterDropdownItem } from "./FilterDropdownItem";
|
|
5
|
+
|
|
6
|
+
export function TimeRangeFilter({ time, onTime }: { time: string; onTime(time: string): void }) {
|
|
7
|
+
return (
|
|
8
|
+
<FilterDropdown
|
|
9
|
+
width={210}
|
|
10
|
+
trigger={(open, toggle) => (
|
|
11
|
+
<FilterDropdownButton open={open} toggle={toggle} keyLabel="Time" value={timeLabel(time)} />
|
|
12
|
+
)}
|
|
13
|
+
>
|
|
14
|
+
{(close) => (
|
|
15
|
+
<>
|
|
16
|
+
<div className="dd-search">
|
|
17
|
+
<input
|
|
18
|
+
placeholder="Relative time (5s, 1m, 2d)…"
|
|
19
|
+
onKeyDown={(e) => {
|
|
20
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
21
|
+
if (e.key === "Enter" && v) {
|
|
22
|
+
onTime(v);
|
|
23
|
+
close();
|
|
24
|
+
}
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
{TIME_OPTIONS.map(([k, l]) => (
|
|
29
|
+
<FilterDropdownItem
|
|
30
|
+
key={k}
|
|
31
|
+
active={time === k}
|
|
32
|
+
label={l}
|
|
33
|
+
onClick={() => {
|
|
34
|
+
onTime(k);
|
|
35
|
+
close();
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
))}
|
|
39
|
+
</>
|
|
40
|
+
)}
|
|
41
|
+
</FilterDropdown>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { localTimeZone, type TimeZoneMode } from "../../lib/time-range";
|
|
2
|
+
import { FilterDropdown } from "./FilterDropdown";
|
|
3
|
+
import { FilterDropdownButton } from "./FilterDropdownButton";
|
|
4
|
+
import { FilterDropdownItem } from "./FilterDropdownItem";
|
|
5
|
+
|
|
6
|
+
const OPTIONS: [TimeZoneMode, string][] = [
|
|
7
|
+
["local", `Local · ${localTimeZone}`],
|
|
8
|
+
["utc", "UTC"],
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function TimeZoneFilter({ tz, onTz }: { tz: TimeZoneMode; onTz(tz: TimeZoneMode): void }) {
|
|
12
|
+
return (
|
|
13
|
+
<FilterDropdown
|
|
14
|
+
align="right"
|
|
15
|
+
width={220}
|
|
16
|
+
trigger={(open, toggle) => (
|
|
17
|
+
<FilterDropdownButton
|
|
18
|
+
open={open}
|
|
19
|
+
toggle={toggle}
|
|
20
|
+
keyLabel="Zone"
|
|
21
|
+
value={tz === "utc" ? "UTC" : "Local"}
|
|
22
|
+
/>
|
|
23
|
+
)}
|
|
24
|
+
>
|
|
25
|
+
{(close) =>
|
|
26
|
+
OPTIONS.map(([k, l]) => (
|
|
27
|
+
<FilterDropdownItem
|
|
28
|
+
key={k}
|
|
29
|
+
active={tz === k}
|
|
30
|
+
label={l}
|
|
31
|
+
onClick={() => {
|
|
32
|
+
onTz(k);
|
|
33
|
+
close();
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
))
|
|
37
|
+
}
|
|
38
|
+
</FilterDropdown>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
export function useClickOutside(ref: RefObject<HTMLElement | null>, onClose: () => void) {
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
const onDown = (e: MouseEvent) => {
|
|
6
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
7
|
+
};
|
|
8
|
+
const onKey = (e: KeyboardEvent) => {
|
|
9
|
+
if (e.key === "Escape") onClose();
|
|
10
|
+
};
|
|
11
|
+
document.addEventListener("mousedown", onDown);
|
|
12
|
+
document.addEventListener("keydown", onKey);
|
|
13
|
+
return () => {
|
|
14
|
+
document.removeEventListener("mousedown", onDown);
|
|
15
|
+
document.removeEventListener("keydown", onKey);
|
|
16
|
+
};
|
|
17
|
+
}, [ref, onClose]);
|
|
18
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { fakeClient, renderWithClient } from "../test-utils";
|
|
4
|
+
import { ALL_FILTER } from "../lib/run-filters";
|
|
5
|
+
import { CursorPager } from "./CursorPager";
|
|
6
|
+
import { RunsFilterBar } from "./RunsFilterBar";
|
|
7
|
+
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
function filterBarProps() {
|
|
11
|
+
return {
|
|
12
|
+
query: "",
|
|
13
|
+
onQuery: noop,
|
|
14
|
+
deep: false,
|
|
15
|
+
onDeep: noop,
|
|
16
|
+
time: "1h",
|
|
17
|
+
onTime: noop,
|
|
18
|
+
status: ALL_FILTER,
|
|
19
|
+
onStatus: noop,
|
|
20
|
+
app: ALL_FILTER,
|
|
21
|
+
onApp: noop,
|
|
22
|
+
runType: ALL_FILTER,
|
|
23
|
+
onRunType: noop,
|
|
24
|
+
shown: 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("RunsFilterBar", () => {
|
|
29
|
+
it("hides Clear until a filter is active, then clears every facet", () => {
|
|
30
|
+
const onStatus = vi.fn();
|
|
31
|
+
const onApp = vi.fn();
|
|
32
|
+
const onRunType = vi.fn();
|
|
33
|
+
const onQuery = vi.fn();
|
|
34
|
+
const onDeep = vi.fn();
|
|
35
|
+
renderWithClient(
|
|
36
|
+
<RunsFilterBar
|
|
37
|
+
{...filterBarProps()}
|
|
38
|
+
status="failed"
|
|
39
|
+
onStatus={onStatus}
|
|
40
|
+
onApp={onApp}
|
|
41
|
+
onRunType={onRunType}
|
|
42
|
+
onQuery={onQuery}
|
|
43
|
+
onDeep={onDeep}
|
|
44
|
+
/>,
|
|
45
|
+
fakeClient(),
|
|
46
|
+
);
|
|
47
|
+
fireEvent.click(screen.getByTitle("Clear all filters"));
|
|
48
|
+
expect(onStatus).toHaveBeenCalledWith("all");
|
|
49
|
+
expect(onApp).toHaveBeenCalledWith("all");
|
|
50
|
+
expect(onRunType).toHaveBeenCalledWith("all");
|
|
51
|
+
expect(onQuery).toHaveBeenCalledWith("");
|
|
52
|
+
expect(onDeep).toHaveBeenCalledWith(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("fires onStatus when a status option is picked", () => {
|
|
56
|
+
const onStatus = vi.fn();
|
|
57
|
+
renderWithClient(<RunsFilterBar {...filterBarProps()} onStatus={onStatus} />, fakeClient());
|
|
58
|
+
fireEvent.click(screen.getByText("Status"));
|
|
59
|
+
fireEvent.click(screen.getByText("Succeeded"));
|
|
60
|
+
expect(onStatus).toHaveBeenCalledWith("succeeded");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("shows the current shown count", () => {
|
|
64
|
+
renderWithClient(<RunsFilterBar {...filterBarProps()} shown={7} />, fakeClient());
|
|
65
|
+
expect(screen.getByText("7 shown")).toBeTruthy();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("CursorPager", () => {
|
|
70
|
+
const base = { rangeStart: 1, rangeEnd: 30, onNewer: noop, onOlder: noop };
|
|
71
|
+
|
|
72
|
+
it("renders nothing on an empty range", () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<CursorPager {...base} rangeEnd={0} canNewer={false} canOlder={false} />,
|
|
75
|
+
);
|
|
76
|
+
expect(container.firstChild).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("disables the bound the cursor cannot cross", () => {
|
|
80
|
+
render(<CursorPager {...base} canNewer={false} canOlder />);
|
|
81
|
+
expect(screen.getByLabelText("Newer runs").hasAttribute("disabled")).toBe(true);
|
|
82
|
+
expect(screen.getByLabelText("Older runs").hasAttribute("disabled")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("fires the nav handlers when enabled", () => {
|
|
86
|
+
const onNewer = vi.fn();
|
|
87
|
+
const onOlder = vi.fn();
|
|
88
|
+
render(<CursorPager {...base} canNewer canOlder onNewer={onNewer} onOlder={onOlder} />);
|
|
89
|
+
fireEvent.click(screen.getByLabelText("Newer runs"));
|
|
90
|
+
fireEvent.click(screen.getByLabelText("Older runs"));
|
|
91
|
+
expect(onNewer).toHaveBeenCalledOnce();
|
|
92
|
+
expect(onOlder).toHaveBeenCalledOnce();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const MARK_BARS = [
|
|
2
|
+
{ x: 3, y: 3, width: 4, height: 18 },
|
|
3
|
+
{ x: 7, y: 3, width: 10, height: 4 },
|
|
4
|
+
{ x: 7, y: 17, width: 10, height: 4 },
|
|
5
|
+
{ x: 17, y: 3, width: 4, height: 18 },
|
|
6
|
+
{ x: 0, y: 10.6, width: 3, height: 2.8 },
|
|
7
|
+
{ x: 21, y: 10.6, width: 3, height: 2.8 },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export const RESUME_TRIANGLE = "M10 8.4 L15.4 12 L10 15.6 Z";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { fakeClient, makeRun, renderWithClient } from "../test-utils";
|
|
4
|
+
import { ReplayRunDialog } from "./ReplayRunDialog";
|
|
5
|
+
|
|
6
|
+
describe("ReplayRunDialog", () => {
|
|
7
|
+
it("replays with no override when the input is unchanged", async () => {
|
|
8
|
+
const replay = vi.fn(async () => makeRun({ id: "run_fork" }));
|
|
9
|
+
const run = makeRun({ id: "run_1", workflowName: "sendEmail", input: { to: "a@b.c" } });
|
|
10
|
+
renderWithClient(
|
|
11
|
+
<ReplayRunDialog run={run} open onOpenChange={() => {}} />,
|
|
12
|
+
fakeClient({ replay }),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
fireEvent.click(await screen.findByText("Replay"));
|
|
16
|
+
await waitFor(() => expect(replay).toHaveBeenCalledWith("run_1", undefined));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import type { LogFrame } from "@durablex/react";
|
|
4
|
+
import { DEFAULT_RUN_SORT } from "../lib/run-sort";
|
|
5
|
+
import { makeRun, makeStep } from "../test-utils";
|
|
6
|
+
import { JsonBlock } from "./JsonBlock";
|
|
7
|
+
import { LogList } from "./LogList";
|
|
8
|
+
import { PayloadTabs } from "./PayloadTabs";
|
|
9
|
+
import { RunsTable } from "./RunsTable";
|
|
10
|
+
import { StepRow } from "./StepRow";
|
|
11
|
+
|
|
12
|
+
const noop = () => {};
|
|
13
|
+
|
|
14
|
+
function logFrame(overrides: Partial<LogFrame> = {}): LogFrame {
|
|
15
|
+
return {
|
|
16
|
+
kind: "log",
|
|
17
|
+
seq: 1,
|
|
18
|
+
ts: "2026-07-01T00:00:00.000Z",
|
|
19
|
+
runId: "run_1",
|
|
20
|
+
level: "info",
|
|
21
|
+
message: "hello world",
|
|
22
|
+
scope: "@root",
|
|
23
|
+
attempt: 1,
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("PayloadTabs", () => {
|
|
29
|
+
it("lands on the Error tab for a failed run", () => {
|
|
30
|
+
const run = makeRun({ status: "failed", error: { message: "kaboom" }, input: { a: 1 } });
|
|
31
|
+
render(<PayloadTabs run={run} logs={[]} />);
|
|
32
|
+
expect(screen.getByText("kaboom")).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("defaults to Input and switches to Result on click", () => {
|
|
36
|
+
const run = makeRun({ status: "succeeded", input: { in: "payload" }, result: { out: "done" } });
|
|
37
|
+
const { container } = render(<PayloadTabs run={run} logs={[]} />);
|
|
38
|
+
expect(container.textContent).toContain('"in"');
|
|
39
|
+
fireEvent.click(screen.getByText("Result"));
|
|
40
|
+
expect(container.textContent).toContain('"out"');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("StepRow", () => {
|
|
45
|
+
it("auto-opens a failed step and shows its error", () => {
|
|
46
|
+
const step = makeStep({ name: "charge", status: "failed", error: { message: "declined" } });
|
|
47
|
+
render(<StepRow step={step} index={1} current={false} runPaused={false} logs={[]} />);
|
|
48
|
+
expect(screen.getByText("charge")).toBeTruthy();
|
|
49
|
+
expect(screen.getByText("declined")).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("expands a succeeded step on click to reveal its output", () => {
|
|
53
|
+
const step = makeStep({ name: "fetch", status: "succeeded", output: { rows: 3 } });
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<StepRow step={step} index={1} current={false} runPaused={false} logs={[]} />,
|
|
56
|
+
);
|
|
57
|
+
expect(container.textContent).not.toContain('"rows"');
|
|
58
|
+
fireEvent.click(screen.getByText("fetch"));
|
|
59
|
+
expect(container.textContent).toContain('"rows"');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("RunsTable", () => {
|
|
64
|
+
const baseProps = {
|
|
65
|
+
selectedId: null,
|
|
66
|
+
onSelect: noop,
|
|
67
|
+
sort: DEFAULT_RUN_SORT,
|
|
68
|
+
onSort: noop,
|
|
69
|
+
emptyTitle: "No runs yet",
|
|
70
|
+
emptyMessage: "Trigger a workflow.",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
it("shows the empty state when there are no rows", () => {
|
|
74
|
+
render(<RunsTable {...baseProps} rows={[]} loading={false} isError={false} error={null} />);
|
|
75
|
+
expect(screen.getByText("No runs yet")).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("shows an error state", () => {
|
|
79
|
+
render(
|
|
80
|
+
<RunsTable {...baseProps} rows={[]} loading={false} isError error={new Error("offline")} />,
|
|
81
|
+
);
|
|
82
|
+
expect(screen.getByText("Failed to load runs")).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders run rows and fires onReplay", () => {
|
|
86
|
+
const onReplay = vi.fn();
|
|
87
|
+
render(
|
|
88
|
+
<RunsTable
|
|
89
|
+
{...baseProps}
|
|
90
|
+
rows={[makeRun({ id: "run_9", workflowName: "nightlyReport" })]}
|
|
91
|
+
loading={false}
|
|
92
|
+
isError={false}
|
|
93
|
+
error={null}
|
|
94
|
+
onReplay={onReplay}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByText("nightlyReport")).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("JsonBlock", () => {
|
|
102
|
+
it("renders a null placeholder", () => {
|
|
103
|
+
render(<JsonBlock value={null} />);
|
|
104
|
+
expect(screen.getByText("null")).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("renders object content as escaped text", () => {
|
|
108
|
+
const { container } = render(<JsonBlock value={{ user: "ada" }} />);
|
|
109
|
+
expect(container.textContent).toContain('"user"');
|
|
110
|
+
expect(container.textContent).toContain('"ada"');
|
|
111
|
+
expect(container.querySelector("script")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("LogList", () => {
|
|
116
|
+
it("shows an empty message with no logs", () => {
|
|
117
|
+
render(<LogList logs={[]} />);
|
|
118
|
+
expect(screen.getByText("No logs.")).toBeTruthy();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("renders log message and level", () => {
|
|
122
|
+
render(<LogList logs={[logFrame({ message: "started", level: "warn" })]} />);
|
|
123
|
+
expect(screen.getByText("started")).toBeTruthy();
|
|
124
|
+
expect(screen.getByText("warn")).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { fakeClient, makeControlAction, makeRun, renderWithClient } from "../test-utils";
|
|
4
|
+
import { BulkReplayButton } from "./BulkReplayButton";
|
|
5
|
+
import { RetryFromStepButton } from "./RetryFromStepButton";
|
|
6
|
+
import { RunCancelButton } from "./RunCancelButton";
|
|
7
|
+
import { RunControlHistory } from "./RunControlHistory";
|
|
8
|
+
import { RunPauseButton } from "./RunPauseButton";
|
|
9
|
+
|
|
10
|
+
describe("RunPauseButton", () => {
|
|
11
|
+
it("pauses a running run", async () => {
|
|
12
|
+
const pause = vi.fn(async () => makeRun({ status: "paused" }));
|
|
13
|
+
renderWithClient(<RunPauseButton id="run_1" status="running" />, fakeClient({ pause }));
|
|
14
|
+
fireEvent.click(screen.getByText("Pause"));
|
|
15
|
+
await waitFor(() => expect(pause).toHaveBeenCalledWith("run_1"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("resumes a paused run", async () => {
|
|
19
|
+
const resume = vi.fn(async () => makeRun({ status: "queued" }));
|
|
20
|
+
renderWithClient(<RunPauseButton id="run_1" status="paused" />, fakeClient({ resume }));
|
|
21
|
+
fireEvent.click(screen.getByText("Resume"));
|
|
22
|
+
await waitFor(() => expect(resume).toHaveBeenCalledWith("run_1"));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("RunCancelButton", () => {
|
|
27
|
+
it("arms on the first click and cancels on the second", async () => {
|
|
28
|
+
const cancel = vi.fn(async () => makeRun({ status: "cancelled" }));
|
|
29
|
+
renderWithClient(<RunCancelButton id="run_1" />, fakeClient({ cancel }));
|
|
30
|
+
|
|
31
|
+
fireEvent.click(screen.getByText("Cancel"));
|
|
32
|
+
expect(cancel).not.toHaveBeenCalled();
|
|
33
|
+
expect(screen.getByText("Confirm cancel")).toBeTruthy();
|
|
34
|
+
|
|
35
|
+
fireEvent.click(screen.getByText("Confirm cancel"));
|
|
36
|
+
await waitFor(() => expect(cancel).toHaveBeenCalledWith("run_1"));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("RetryFromStepButton", () => {
|
|
41
|
+
it("forks from the step on the confirmed click", async () => {
|
|
42
|
+
const retryFromStep = vi.fn(async () => makeRun({ id: "run_fork" }));
|
|
43
|
+
renderWithClient(
|
|
44
|
+
<RetryFromStepButton runId="run_1" stepName="charge" />,
|
|
45
|
+
fakeClient({ retryFromStep }),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
fireEvent.click(screen.getByText("Retry from here"));
|
|
49
|
+
expect(retryFromStep).not.toHaveBeenCalled();
|
|
50
|
+
|
|
51
|
+
fireEvent.click(screen.getByText("Confirm retry"));
|
|
52
|
+
await waitFor(() => expect(retryFromStep).toHaveBeenCalledWith("run_1", "charge"));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("BulkReplayButton", () => {
|
|
57
|
+
it("replays the filter on the confirmed click and reports the count", async () => {
|
|
58
|
+
const bulkReplay = vi.fn(async () => ({
|
|
59
|
+
matched: 5,
|
|
60
|
+
replayed: 3,
|
|
61
|
+
skipped: 2,
|
|
62
|
+
failed: 0,
|
|
63
|
+
capped: false,
|
|
64
|
+
}));
|
|
65
|
+
renderWithClient(<BulkReplayButton filter={{ app: "default" }} />, fakeClient({ bulkReplay }));
|
|
66
|
+
|
|
67
|
+
fireEvent.click(screen.getByText("Replay matching"));
|
|
68
|
+
fireEvent.click(screen.getByText("Confirm replay"));
|
|
69
|
+
await waitFor(() => expect(bulkReplay).toHaveBeenCalledWith({ app: "default" }));
|
|
70
|
+
await waitFor(() => expect(screen.getByText("Replayed 3")).toBeTruthy());
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("RunControlHistory", () => {
|
|
75
|
+
it("renders nothing when there is no history", () => {
|
|
76
|
+
const { container } = renderWithClient(
|
|
77
|
+
<RunControlHistory runId="run_1" />,
|
|
78
|
+
fakeClient({ controlActions: [] }),
|
|
79
|
+
);
|
|
80
|
+
expect(container.querySelector(".sec-title")).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("links to a forked run and fires onOpenRun", async () => {
|
|
84
|
+
const onOpenRun = vi.fn();
|
|
85
|
+
renderWithClient(
|
|
86
|
+
<RunControlHistory runId="run_1" onOpenRun={onOpenRun} />,
|
|
87
|
+
fakeClient({
|
|
88
|
+
controlActions: [
|
|
89
|
+
makeControlAction({ action: "replay", runId: "run_1", newRunId: "run_fork" }),
|
|
90
|
+
],
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const link = await screen.findByText("run_fork");
|
|
94
|
+
fireEvent.click(link);
|
|
95
|
+
expect(onOpenRun).toHaveBeenCalledWith("run_fork");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
const CONFIRM_MS = 3000;
|
|
4
|
+
|
|
5
|
+
// Two-click confirm for a destructive or bulk action: the first trigger arms and
|
|
6
|
+
// returns; a second trigger within the window runs the action. Auto-disarms after.
|
|
7
|
+
export function useConfirmAction(action: () => void, ms = CONFIRM_MS) {
|
|
8
|
+
const [confirming, setConfirming] = useState(false);
|
|
9
|
+
const trigger = () => {
|
|
10
|
+
if (!confirming) {
|
|
11
|
+
setConfirming(true);
|
|
12
|
+
setTimeout(() => setConfirming(false), ms);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
setConfirming(false);
|
|
16
|
+
action();
|
|
17
|
+
};
|
|
18
|
+
return { confirming, trigger };
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// Copy text and flip `copied` for a beat. navigator.clipboard is undefined in non-secure
|
|
4
|
+
// contexts (http on a non-localhost host), so guard it rather than throwing on click.
|
|
5
|
+
export function useCopyToClipboard(resetMs = 1200) {
|
|
6
|
+
const [copied, setCopied] = useState(false);
|
|
7
|
+
|
|
8
|
+
const copy = useCallback(
|
|
9
|
+
(text: string) => {
|
|
10
|
+
void navigator.clipboard?.writeText(text).then(
|
|
11
|
+
() => {
|
|
12
|
+
setCopied(true);
|
|
13
|
+
setTimeout(() => setCopied(false), resetMs);
|
|
14
|
+
},
|
|
15
|
+
() => undefined,
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
[resetMs],
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return { copied, copy };
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
// Keyset cursor stack: `cursor` loads the current page; the stack holds the cursors of
|
|
4
|
+
// earlier pages so "Newer" can walk back. Page 0 (newest) uses no cursor. Passing a new
|
|
5
|
+
// `resetKey` (a filter/sort signature) snaps back to the newest page during render.
|
|
6
|
+
export function useKeysetPager(resetKey: string, pageSize: number) {
|
|
7
|
+
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
|
8
|
+
const [prev, setPrev] = useState<(string | undefined)[]>([]);
|
|
9
|
+
|
|
10
|
+
const [pageKey, setPageKey] = useState(resetKey);
|
|
11
|
+
if (pageKey !== resetKey) {
|
|
12
|
+
setPageKey(resetKey);
|
|
13
|
+
setCursor(undefined);
|
|
14
|
+
setPrev([]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const depth = prev.length;
|
|
18
|
+
|
|
19
|
+
function range(rowCount: number) {
|
|
20
|
+
return { start: rowCount ? depth * pageSize + 1 : 0, end: depth * pageSize + rowCount };
|
|
21
|
+
}
|
|
22
|
+
function goOlder(nextCursor: string | null) {
|
|
23
|
+
if (!nextCursor) return;
|
|
24
|
+
setPrev((s) => [...s, cursor]);
|
|
25
|
+
setCursor(nextCursor);
|
|
26
|
+
}
|
|
27
|
+
function goNewer() {
|
|
28
|
+
if (prev.length === 0) return;
|
|
29
|
+
setCursor(prev[prev.length - 1]);
|
|
30
|
+
setPrev((s) => s.slice(0, -1));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { cursor, canNewer: depth > 0, range, goOlder, goNewer };
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768;
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
|
7
|
+
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
10
|
+
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
11
|
+
mql.addEventListener("change", onChange);
|
|
12
|
+
return () => mql.removeEventListener("change", onChange);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
return isMobile;
|
|
16
|
+
}
|