mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): Workflows B5a.1 — View runs → inspector + New workflow button (#4474)
This commit is contained in:
@@ -6,6 +6,8 @@ interface EmptyStateCardProps {
|
||||
description: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
/** `data-testid` for the action button, so callers can target it distinctly from other same-labeled buttons on the page. */
|
||||
actionTestId?: string;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -16,6 +18,7 @@ const EmptyStateCard = ({
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
actionTestId,
|
||||
footer,
|
||||
className = '',
|
||||
}: EmptyStateCardProps) => {
|
||||
@@ -30,6 +33,7 @@ const EmptyStateCard = ({
|
||||
{actionLabel && onAction ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={actionTestId}
|
||||
onClick={onAction}
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 border border-primary-100 dark:border-primary-800/50 transition-colors hover:bg-primary-100 dark:hover:bg-primary-500/20">
|
||||
<span>{actionLabel}</span>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* FlowListRow (issue B5a) — one saved-flow row on the Workflows list page.
|
||||
* Asserts the name/status rendering, the last-run/never-run text (including
|
||||
* the localized relative-time strings), and that the toggle/Run controls
|
||||
* call back with the row's `Flow`. No "View runs" control yet — it was
|
||||
* pulled until B3b's run inspector lands (see `FlowListRow.tsx`'s module
|
||||
* doc and the commented integration point in `FlowsPage.tsx`).
|
||||
* FlowListRow (issue B5a / B5a.1) — one saved-flow row on the Workflows list
|
||||
* page. Asserts the name/status rendering, the last-run/never-run text
|
||||
* (including the localized relative-time strings), and that the
|
||||
* toggle/Run/View runs controls call back with the row's `Flow`.
|
||||
*/
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
@@ -30,7 +28,9 @@ function makeFlow(overrides: Partial<Flow> = {}): Flow {
|
||||
|
||||
describe('FlowListRow', () => {
|
||||
it('renders the flow name and an Enabled badge when enabled', () => {
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Daily digest')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled');
|
||||
@@ -38,14 +38,21 @@ describe('FlowListRow', () => {
|
||||
|
||||
it('renders a Paused badge when disabled', () => {
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow({ enabled: false })} onToggle={vi.fn()} onRun={vi.fn()} />
|
||||
<FlowListRow
|
||||
flow={makeFlow({ enabled: false })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused');
|
||||
});
|
||||
|
||||
it('shows "Never run" when the flow has no last_run_at', () => {
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Never run')).toBeInTheDocument();
|
||||
});
|
||||
@@ -56,6 +63,7 @@ describe('FlowListRow', () => {
|
||||
flow={makeFlow({ last_run_at: new Date().toISOString(), last_status: 'completed' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -69,6 +77,7 @@ describe('FlowListRow', () => {
|
||||
flow={makeFlow({ last_run_at: fiveMinAgo, last_status: 'completed' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -82,6 +91,7 @@ describe('FlowListRow', () => {
|
||||
flow={makeFlow({ last_run_at: threeHoursAgo, last_status: 'failed' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -95,6 +105,7 @@ describe('FlowListRow', () => {
|
||||
flow={makeFlow({ last_run_at: twoDaysAgo, last_status: 'pending_approval' })}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -103,7 +114,9 @@ describe('FlowListRow', () => {
|
||||
|
||||
it('calls onToggle with the flow when the switch is clicked', () => {
|
||||
const onToggle = vi.fn();
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={onToggle} onRun={vi.fn()} />);
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={onToggle} onRun={vi.fn()} onViewRuns={vi.fn()} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));
|
||||
|
||||
@@ -112,16 +125,37 @@ describe('FlowListRow', () => {
|
||||
|
||||
it('calls onRun with the flow when the Run button is clicked', () => {
|
||||
const onRun = vi.fn();
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={onRun} />);
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={onRun} onViewRuns={vi.fn()} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
|
||||
|
||||
expect(onRun).toHaveBeenCalledWith(makeFlow());
|
||||
});
|
||||
|
||||
it('renders a "View runs" control and calls onViewRuns with the flow when clicked', () => {
|
||||
const onViewRuns = vi.fn();
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={onViewRuns} />
|
||||
);
|
||||
|
||||
const viewRunsButton = screen.getByTestId('flow-view-runs-flow-1');
|
||||
expect(viewRunsButton).toHaveTextContent('View runs');
|
||||
fireEvent.click(viewRunsButton);
|
||||
|
||||
expect(onViewRuns).toHaveBeenCalledWith(makeFlow());
|
||||
});
|
||||
|
||||
it('shows the running label and disables Run while busy', () => {
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="run" />
|
||||
<FlowListRow
|
||||
flow={makeFlow()}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
busy="run"
|
||||
/>
|
||||
);
|
||||
|
||||
const runButton = screen.getByTestId('flow-run-flow-1');
|
||||
@@ -131,16 +165,15 @@ describe('FlowListRow', () => {
|
||||
|
||||
it('disables the toggle while busy=toggle', () => {
|
||||
renderWithProviders(
|
||||
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="toggle" />
|
||||
<FlowListRow
|
||||
flow={makeFlow()}
|
||||
onToggle={vi.fn()}
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
busy="toggle"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not render a "View runs" control', () => {
|
||||
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId('flow-view-runs-flow-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('View runs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
* canonical boolean control — see `components/settings/controls`) since
|
||||
* enable/disable here is a persistent setting, not a one-off action.
|
||||
*
|
||||
* No "View runs" action yet: it would only stub-log a `selectedFlowId` with
|
||||
* nothing to show for it until B3b's run inspector lands (tracked as a
|
||||
* commented-out integration point in `FlowsPage.tsx`), so it's a dead button
|
||||
* until then and was pulled rather than shipped as a no-op.
|
||||
* "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`)
|
||||
* for this flow's run history — re-added now that B3b's run inspector has
|
||||
* landed and the drawer has somewhere to send the user.
|
||||
*/
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { Flow } from '../../services/api/flowsApi';
|
||||
@@ -28,6 +27,7 @@ export interface FlowListRowProps {
|
||||
flow: Flow;
|
||||
onToggle: (flow: Flow) => void;
|
||||
onRun: (flow: Flow) => void;
|
||||
onViewRuns: (flow: Flow) => void;
|
||||
busy?: FlowListRowBusy;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ function capitalize(value: string): string {
|
||||
return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
|
||||
}
|
||||
|
||||
const FlowListRow = ({ flow, onToggle, onRun, busy = null }: FlowListRowProps) => {
|
||||
const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowListRowProps) => {
|
||||
const { t } = useT();
|
||||
const toggleBusy = busy === 'toggle';
|
||||
const runBusy = busy === 'run';
|
||||
@@ -95,6 +95,14 @@ const FlowListRow = ({ flow, onToggle, onRun, busy = null }: FlowListRowProps) =
|
||||
aria-label={t('flows.list.toggleEnabled')}
|
||||
onCheckedChange={() => onToggle(flow)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-testid={`flow-view-runs-${flow.id}`}
|
||||
onClick={() => onViewRuns(flow)}>
|
||||
{t('flows.list.viewRuns')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
||||
@@ -30,8 +30,13 @@ import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi';
|
||||
|
||||
const log = debug('flows:run-inspector-drawer');
|
||||
|
||||
/** Accent classes per run status (semantic palette from tailwind.config.js). */
|
||||
const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
|
||||
/**
|
||||
* Accent classes per run status (semantic palette from tailwind.config.js).
|
||||
* Exported so {@link FlowRunsDrawer} (issue B5a.1) can reuse the same
|
||||
* status-pill visual language for its run-history rows instead of
|
||||
* duplicating the mapping.
|
||||
*/
|
||||
export const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
|
||||
running:
|
||||
'border-ocean-200 bg-ocean-50 text-ocean-700 dark:border-ocean-500/30 dark:bg-ocean-500/10 dark:text-ocean-300',
|
||||
completed:
|
||||
@@ -42,15 +47,16 @@ const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
|
||||
'border-coral-200 bg-coral-50 text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300',
|
||||
};
|
||||
|
||||
/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. */
|
||||
const FLOW_RUN_STATUS_DOT: Record<FlowRunStatus, string> = {
|
||||
/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. Exported, see above. */
|
||||
export const FLOW_RUN_STATUS_DOT: Record<FlowRunStatus, string> = {
|
||||
running: 'bg-ocean-500 animate-pulse',
|
||||
completed: 'bg-sage-500',
|
||||
pending_approval: 'bg-amber-500 animate-pulse',
|
||||
failed: 'bg-coral-500',
|
||||
};
|
||||
|
||||
const FLOW_RUN_STATUS_KEY: Record<FlowRunStatus, string> = {
|
||||
/** i18n key per run status. Exported, see above. */
|
||||
export const FLOW_RUN_STATUS_KEY: Record<FlowRunStatus, string> = {
|
||||
running: 'flowRuns.status.running',
|
||||
completed: 'flowRuns.status.completed',
|
||||
pending_approval: 'flowRuns.status.pending_approval',
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* FlowRunsDrawer (issue B5a.1) — rendering contract.
|
||||
*
|
||||
* Asserts: renders null when `flowId` is null; loading state; renders fetched
|
||||
* runs (status pill, started-at, truncated id); empty state; error state;
|
||||
* clicking a run opens `FlowRunInspectorDrawer` for it, stacked on top;
|
||||
* closing the inspector returns to the runs list; Escape/backdrop/✕ close the
|
||||
* runs drawer itself.
|
||||
*
|
||||
* Mocks `flowsApi.listFlowRuns` (the one-shot fetch this drawer owns) and
|
||||
* `FlowRunInspectorDrawer` (its own behavior is covered by
|
||||
* `FlowRunInspectorDrawer.test.tsx`) so this suite only exercises the
|
||||
* run-history list + the nesting contract between the two drawers.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowRun } from '../../services/api/flowsApi';
|
||||
import { store } from '../../store';
|
||||
import { FlowRunsDrawer } from './FlowRunsDrawer';
|
||||
|
||||
const listFlowRuns = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns }));
|
||||
|
||||
const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./FlowRunInspectorDrawer', () => ({
|
||||
FLOW_RUN_STATUS_ACCENT: {
|
||||
running: 'accent-running',
|
||||
completed: 'accent-completed',
|
||||
pending_approval: 'accent-pending',
|
||||
failed: 'accent-failed',
|
||||
},
|
||||
FLOW_RUN_STATUS_DOT: {
|
||||
running: 'dot-running',
|
||||
completed: 'dot-completed',
|
||||
pending_approval: 'dot-pending',
|
||||
failed: 'dot-failed',
|
||||
},
|
||||
FLOW_RUN_STATUS_KEY: {
|
||||
running: 'flowRuns.status.running',
|
||||
completed: 'flowRuns.status.completed',
|
||||
pending_approval: 'flowRuns.status.pending_approval',
|
||||
failed: 'flowRuns.status.failed',
|
||||
},
|
||||
FlowRunInspectorDrawer: (props: { runId: string | null; onClose: () => void }) => {
|
||||
FlowRunInspectorDrawer(props);
|
||||
if (!props.runId) return null;
|
||||
return (
|
||||
<div data-testid="mock-inspector">
|
||||
<span>{props.runId}</span>
|
||||
<button type="button" data-testid="mock-inspector-close" onClick={props.onClose}>
|
||||
close inspector
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
function makeRun(overrides: Partial<FlowRun> = {}): FlowRun {
|
||||
return {
|
||||
id: 'run-1',
|
||||
flow_id: 'flow-1',
|
||||
thread_id: 'run-1',
|
||||
status: 'completed',
|
||||
started_at: '2026-01-01T00:00:00Z',
|
||||
steps: [],
|
||||
pending_approvals: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDrawer(flowId: string | null, onClose: () => void, flowName?: string) {
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<FlowRunsDrawer flowId={flowId} flowName={flowName} onClose={onClose} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('FlowRunsDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders null when flowId is null', () => {
|
||||
const { container } = renderDrawer(null, vi.fn());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(listFlowRuns).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a loading state before the fetch resolves', () => {
|
||||
listFlowRuns.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
expect(screen.getByTestId('flow-runs-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches and lists runs for the flow', async () => {
|
||||
listFlowRuns.mockResolvedValue([
|
||||
makeRun({ id: 'run-1', status: 'completed' }),
|
||||
makeRun({ id: 'run-2', status: 'failed' }),
|
||||
]);
|
||||
renderDrawer('flow-1', vi.fn(), 'Daily digest');
|
||||
|
||||
expect(await screen.findByTestId('flow-runs-list')).toBeInTheDocument();
|
||||
expect(listFlowRuns).toHaveBeenCalledWith('flow-1');
|
||||
expect(screen.getByTestId('flow-run-row-run-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-run-row-run-2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Runs for Daily digest')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to a generic title when no flowName is given', async () => {
|
||||
listFlowRuns.mockResolvedValue([]);
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument());
|
||||
expect(screen.getByText('Workflow runs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no runs', async () => {
|
||||
listFlowRuns.mockResolvedValue([]);
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
expect(await screen.findByTestId('flow-runs-empty')).toHaveTextContent('No runs yet');
|
||||
});
|
||||
|
||||
it('shows an error state when the fetch fails', async () => {
|
||||
listFlowRuns.mockRejectedValue(new Error('core unreachable'));
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
expect(await screen.findByTestId('flow-runs-error')).toHaveTextContent('core unreachable');
|
||||
});
|
||||
|
||||
it('opens the run inspector on top when a run row is clicked', async () => {
|
||||
listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]);
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
|
||||
const row = await screen.findByTestId('flow-run-row-run-1');
|
||||
fireEvent.click(row);
|
||||
|
||||
expect(await screen.findByTestId('mock-inspector')).toHaveTextContent('run-1');
|
||||
// The runs list stays mounted underneath.
|
||||
expect(screen.getByTestId('flow-runs-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the run list when the inspector closes', async () => {
|
||||
listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]);
|
||||
renderDrawer('flow-1', vi.fn());
|
||||
|
||||
fireEvent.click(await screen.findByTestId('flow-run-row-run-1'));
|
||||
expect(await screen.findByTestId('mock-inspector')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('mock-inspector-close'));
|
||||
expect(screen.queryByTestId('mock-inspector')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-runs-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when the close button is clicked', async () => {
|
||||
listFlowRuns.mockResolvedValue([]);
|
||||
const onClose = vi.fn();
|
||||
renderDrawer('flow-1', onClose);
|
||||
await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-runs-close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when the backdrop is clicked', async () => {
|
||||
listFlowRuns.mockResolvedValue([]);
|
||||
const onClose = vi.fn();
|
||||
renderDrawer('flow-1', onClose);
|
||||
await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-runs-backdrop'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClose when Escape is pressed and no run is selected', async () => {
|
||||
listFlowRuns.mockResolvedValue([]);
|
||||
const onClose = vi.fn();
|
||||
renderDrawer('flow-1', onClose);
|
||||
await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument());
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not close the runs drawer on Escape while the inspector is open', async () => {
|
||||
listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]);
|
||||
const onClose = vi.fn();
|
||||
renderDrawer('flow-1', onClose);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('flow-run-row-run-1'));
|
||||
expect(await screen.findByTestId('mock-inspector')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* FlowRunsDrawer (issue B5a.1)
|
||||
* ----------------------------
|
||||
*
|
||||
* Right-side drawer listing a flow's run history, opened from the
|
||||
* "View runs" action on {@link FlowListRow}. Drawer chrome mirrors
|
||||
* `FlowRunInspectorDrawer`/`SubagentDrawer` (fixed overlay + backdrop-click-
|
||||
* to-close + Escape-to-close via `useEscapeKey`) so it renders as a fixed
|
||||
* overlay regardless of where the parent mounts it.
|
||||
*
|
||||
* Data is a one-shot fetch via `listFlowRuns` — no polling here. The run
|
||||
* inspector already polls a single run's live status via `useFlowRunPoller`;
|
||||
* polling the whole list here would duplicate that logic for no benefit
|
||||
* (the list only needs to be fresh when the drawer opens).
|
||||
*
|
||||
* Clicking a run sets `selectedRunId` and renders the existing
|
||||
* `FlowRunInspectorDrawer` stacked on top: both are `fixed inset-0 z-50`
|
||||
* overlays, and the inspector is rendered *after* this drawer's own overlay
|
||||
* in the JSX, so it paints above it (same stacking context, later DOM wins)
|
||||
* and its backdrop naturally intercepts clicks meant for the runs list.
|
||||
* Closing the inspector clears `selectedRunId` and returns to the run list;
|
||||
* closing this drawer (✕ / backdrop / Escape) calls `onClose`. While the
|
||||
* inspector is open, this drawer's own Escape handler is disabled so a
|
||||
* single Escape press closes only the topmost overlay (the inspector) first.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi';
|
||||
import {
|
||||
FLOW_RUN_STATUS_ACCENT,
|
||||
FLOW_RUN_STATUS_DOT,
|
||||
FLOW_RUN_STATUS_KEY,
|
||||
FlowRunInspectorDrawer,
|
||||
} from './FlowRunInspectorDrawer';
|
||||
|
||||
const log = debug('flows:runs-drawer');
|
||||
|
||||
function formatTimestamp(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(parsed));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Flow to list runs for. Renders `null` (nothing) when absent. */
|
||||
flowId: string | null;
|
||||
/** Flow name for the drawer title, when known. */
|
||||
flowName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `null` when `flowId` is `null` so the parent can mount this
|
||||
* unconditionally and just flip `flowId` (same convention as
|
||||
* `FlowRunInspectorDrawer`/`SubagentDrawer`).
|
||||
*/
|
||||
export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) {
|
||||
const { t } = useT();
|
||||
const [runs, setRuns] = useState<FlowRun[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset for the new target so a previous flow's runs/error can't linger
|
||||
// under a different flowId while the new fetch is in flight.
|
||||
setSelectedRunId(null);
|
||||
setError(null);
|
||||
|
||||
if (!flowId) {
|
||||
setRuns([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
log('loading runs: flowId=%s', flowId);
|
||||
listFlowRuns(flowId)
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
setRuns(result);
|
||||
log('loaded runs: flowId=%s count=%d', flowId, result.length);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('load failed: flowId=%s err=%s', flowId, msg);
|
||||
setError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [flowId]);
|
||||
|
||||
useEscapeKey(
|
||||
() => {
|
||||
log('escape: closing flowId=%s', flowId);
|
||||
onClose();
|
||||
},
|
||||
flowId !== null && selectedRunId === null
|
||||
);
|
||||
|
||||
if (!flowId) return null;
|
||||
|
||||
const title = flowName
|
||||
? t('flows.runs.title').replace('{name}', flowName)
|
||||
: t('flows.runs.titleFallback');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex justify-end" data-testid="flow-runs-drawer">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('conversations.subagent.close')}
|
||||
data-testid="flow-runs-backdrop"
|
||||
className="absolute inset-0 bg-stone-900/30 dark:bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="relative flex h-full w-full max-w-md flex-col bg-surface shadow-xl">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="flow-runs-close"
|
||||
onClick={onClose}
|
||||
aria-label={t('conversations.subagent.close')}
|
||||
className="shrink-0 rounded-full p-1.5 text-content-faint hover:bg-surface-hover hover:text-content-secondary">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{loading && (
|
||||
<div
|
||||
className="flex items-center gap-2 py-8 text-content-faint"
|
||||
data-testid="flow-runs-loading">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
|
||||
<span className="text-sm">{t('flows.runs.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="flow-runs-error"
|
||||
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('flows.runs.loadError')}: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && runs.length === 0 && (
|
||||
<p
|
||||
className="py-8 text-center text-xs italic text-content-faint"
|
||||
data-testid="flow-runs-empty">
|
||||
{t('flows.runs.empty')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && runs.length > 0 && (
|
||||
<ul className="space-y-2" data-testid="flow-runs-list">
|
||||
{runs.map(run => {
|
||||
const startedAt = formatTimestamp(run.started_at);
|
||||
return (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`flow-run-row-${run.id}`}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-line bg-surface-muted px-3 py-2 text-left text-xs hover:bg-surface-hover">
|
||||
<span
|
||||
data-testid={`flow-run-row-dot-${run.id}`}
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
|
||||
{t(FLOW_RUN_STATUS_KEY[run.status])}
|
||||
</span>
|
||||
{startedAt && (
|
||||
<span className="truncate text-content-muted">{startedAt}</span>
|
||||
)}
|
||||
<span className="ml-auto truncate font-mono text-[10px] text-content-faint">
|
||||
{run.id.slice(0, 8)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{selectedRunId && (
|
||||
<FlowRunInspectorDrawer runId={selectedRunId} onClose={() => setSelectedRunId(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowRunsDrawer;
|
||||
@@ -3584,6 +3584,7 @@ const messages: TranslationMap = {
|
||||
'ستظهر عمليات سير العمل المحفوظة هنا بمجرد إنشاء واحدة من لوحة الرسم.',
|
||||
'flows.page.loading': 'جارٍ تحميل عمليات سير العمل…',
|
||||
'flows.page.loadError': 'تعذر تحميل عمليات سير العمل. يرجى المحاولة مرة أخرى.',
|
||||
'flows.page.newWorkflow': 'سير عمل جديد',
|
||||
'flows.list.lastRun': 'آخر تشغيل',
|
||||
'flows.list.neverRun': 'لم يتم التشغيل بعد',
|
||||
'flows.list.justNow': 'الآن',
|
||||
@@ -3597,6 +3598,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'مفعّل',
|
||||
'flows.list.paused': 'متوقف مؤقتًا',
|
||||
'flows.list.runStarted': 'بدأ تشغيل سير العمل',
|
||||
'flows.runs.title': 'عمليات التشغيل لـ {name}',
|
||||
'flows.runs.titleFallback': 'عمليات تشغيل سير العمل',
|
||||
'flows.runs.loading': 'جارٍ تحميل عمليات التشغيل…',
|
||||
'flows.runs.loadError': 'تعذّر تحميل عمليات التشغيل',
|
||||
'flows.runs.empty': 'لا توجد عمليات تشغيل بعد',
|
||||
|
||||
'oauth.button.connecting': 'جارٍ الاتصال...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3666,6 +3666,7 @@ const messages: TranslationMap = {
|
||||
'ক্যানভাস থেকে একটি তৈরি করলে সংরক্ষিত ওয়ার্কফ্লোগুলো এখানে দেখা যাবে।',
|
||||
'flows.page.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…',
|
||||
'flows.page.loadError': 'ওয়ার্কফ্লো লোড করা যায়নি। আবার চেষ্টা করুন।',
|
||||
'flows.page.newWorkflow': 'নতুন ওয়ার্কফ্লো',
|
||||
'flows.list.lastRun': 'সর্বশেষ চালানো',
|
||||
'flows.list.neverRun': 'কখনো চালানো হয়নি',
|
||||
'flows.list.justNow': 'এইমাত্র',
|
||||
@@ -3679,6 +3680,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'সক্ষম',
|
||||
'flows.list.paused': 'বিরতি দেওয়া',
|
||||
'flows.list.runStarted': 'ওয়ার্কফ্লো শুরু হয়েছে',
|
||||
'flows.runs.title': '{name}-এর জন্য রান',
|
||||
'flows.runs.titleFallback': 'ওয়ার্কফ্লো রান',
|
||||
'flows.runs.loading': 'রান লোড হচ্ছে…',
|
||||
'flows.runs.loadError': 'রান লোড করা যায়নি',
|
||||
'flows.runs.empty': 'এখনো কোনো রান নেই',
|
||||
|
||||
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3756,6 +3756,7 @@ const messages: TranslationMap = {
|
||||
'Gespeicherte Workflows erscheinen hier, sobald du einen im Canvas erstellst.',
|
||||
'flows.page.loading': 'Workflows werden geladen…',
|
||||
'flows.page.loadError': 'Workflows konnten nicht geladen werden. Bitte versuche es erneut.',
|
||||
'flows.page.newWorkflow': 'Neuer Workflow',
|
||||
'flows.list.lastRun': 'Letzter Lauf',
|
||||
'flows.list.neverRun': 'Noch nie ausgeführt',
|
||||
'flows.list.justNow': 'Gerade eben',
|
||||
@@ -3769,6 +3770,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Aktiviert',
|
||||
'flows.list.paused': 'Pausiert',
|
||||
'flows.list.runStarted': 'Workflow gestartet',
|
||||
'flows.runs.title': 'Ausführungen für {name}',
|
||||
'flows.runs.titleFallback': 'Workflow-Ausführungen',
|
||||
'flows.runs.loading': 'Ausführungen werden geladen…',
|
||||
'flows.runs.loadError': 'Ausführungen konnten nicht geladen werden',
|
||||
'flows.runs.empty': 'Noch keine Ausführungen',
|
||||
|
||||
'oauth.button.connecting': 'Verbinden...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -4309,6 +4309,7 @@ const en: TranslationMap = {
|
||||
'Saved workflows will show up here once you create one from the canvas.',
|
||||
'flows.page.loading': 'Loading workflows…',
|
||||
'flows.page.loadError': 'Could not load workflows. Please try again.',
|
||||
'flows.page.newWorkflow': 'New workflow',
|
||||
'flows.list.lastRun': 'Last run',
|
||||
'flows.list.neverRun': 'Never run',
|
||||
'flows.list.justNow': 'Just now',
|
||||
@@ -4322,6 +4323,11 @@ const en: TranslationMap = {
|
||||
'flows.list.enabled': 'Enabled',
|
||||
'flows.list.paused': 'Paused',
|
||||
'flows.list.runStarted': 'Workflow started',
|
||||
'flows.runs.title': 'Runs for {name}',
|
||||
'flows.runs.titleFallback': 'Workflow runs',
|
||||
'flows.runs.loading': 'Loading runs…',
|
||||
'flows.runs.loadError': 'Could not load runs',
|
||||
'flows.runs.empty': 'No runs yet',
|
||||
|
||||
'oauth.button.connecting': 'Connecting...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3729,6 +3729,7 @@ const messages: TranslationMap = {
|
||||
'Los flujos de trabajo guardados aparecerán aquí en cuanto crees uno desde el lienzo.',
|
||||
'flows.page.loading': 'Cargando flujos de trabajo…',
|
||||
'flows.page.loadError': 'No se pudieron cargar los flujos de trabajo. Inténtalo de nuevo.',
|
||||
'flows.page.newWorkflow': 'Nuevo flujo de trabajo',
|
||||
'flows.list.lastRun': 'Última ejecución',
|
||||
'flows.list.neverRun': 'Nunca ejecutado',
|
||||
'flows.list.justNow': 'Justo ahora',
|
||||
@@ -3742,6 +3743,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Habilitado',
|
||||
'flows.list.paused': 'Pausado',
|
||||
'flows.list.runStarted': 'Flujo de trabajo iniciado',
|
||||
'flows.runs.title': 'Ejecuciones de {name}',
|
||||
'flows.runs.titleFallback': 'Ejecuciones del flujo de trabajo',
|
||||
'flows.runs.loading': 'Cargando ejecuciones…',
|
||||
'flows.runs.loadError': 'No se pudieron cargar las ejecuciones',
|
||||
'flows.runs.empty': 'Aún no hay ejecuciones',
|
||||
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3744,6 +3744,7 @@ const messages: TranslationMap = {
|
||||
'Les workflows enregistrés apparaîtront ici dès que vous en créerez un depuis le canevas.',
|
||||
'flows.page.loading': 'Chargement des workflows…',
|
||||
'flows.page.loadError': 'Impossible de charger les workflows. Veuillez réessayer.',
|
||||
'flows.page.newWorkflow': 'Nouveau workflow',
|
||||
'flows.list.lastRun': 'Dernière exécution',
|
||||
'flows.list.neverRun': 'Jamais exécuté',
|
||||
'flows.list.justNow': "À l'instant",
|
||||
@@ -3757,6 +3758,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Activé',
|
||||
'flows.list.paused': 'En pause',
|
||||
'flows.list.runStarted': 'Workflow démarré',
|
||||
'flows.runs.title': 'Exécutions de {name}',
|
||||
'flows.runs.titleFallback': 'Exécutions du workflow',
|
||||
'flows.runs.loading': 'Chargement des exécutions…',
|
||||
'flows.runs.loadError': 'Impossible de charger les exécutions',
|
||||
'flows.runs.empty': 'Aucune exécution pour le moment',
|
||||
|
||||
'oauth.button.connecting': 'Connexion en cours…',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3665,6 +3665,7 @@ const messages: TranslationMap = {
|
||||
'flows.page.emptyDescription': 'कैनवास से एक बनाने के बाद सहेजे गए वर्कफ़्लो यहां दिखाई देंगे।',
|
||||
'flows.page.loading': 'वर्कफ़्लो लोड हो रहे हैं…',
|
||||
'flows.page.loadError': 'वर्कफ़्लो लोड नहीं हो सके। कृपया फिर से प्रयास करें।',
|
||||
'flows.page.newWorkflow': 'नया वर्कफ़्लो',
|
||||
'flows.list.lastRun': 'अंतिम रन',
|
||||
'flows.list.neverRun': 'कभी नहीं चला',
|
||||
'flows.list.justNow': 'अभी अभी',
|
||||
@@ -3678,6 +3679,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'सक्षम',
|
||||
'flows.list.paused': 'रोका गया',
|
||||
'flows.list.runStarted': 'वर्कफ़्लो शुरू हुआ',
|
||||
'flows.runs.title': '{name} के लिए रन',
|
||||
'flows.runs.titleFallback': 'वर्कफ़्लो रन',
|
||||
'flows.runs.loading': 'रन लोड हो रहे हैं…',
|
||||
'flows.runs.loadError': 'रन लोड नहीं हो सके',
|
||||
'flows.runs.empty': 'अभी तक कोई रन नहीं',
|
||||
|
||||
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3674,6 +3674,7 @@ const messages: TranslationMap = {
|
||||
'Alur kerja tersimpan akan muncul di sini setelah Anda membuat satu dari kanvas.',
|
||||
'flows.page.loading': 'Memuat alur kerja…',
|
||||
'flows.page.loadError': 'Alur kerja gagal dimuat. Silakan coba lagi.',
|
||||
'flows.page.newWorkflow': 'Alur Kerja Baru',
|
||||
'flows.list.lastRun': 'Terakhir dijalankan',
|
||||
'flows.list.neverRun': 'Belum pernah dijalankan',
|
||||
'flows.list.justNow': 'Baru saja',
|
||||
@@ -3687,6 +3688,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Aktif',
|
||||
'flows.list.paused': 'Dijeda',
|
||||
'flows.list.runStarted': 'Alur kerja dimulai',
|
||||
'flows.runs.title': 'Proses untuk {name}',
|
||||
'flows.runs.titleFallback': 'Proses alur kerja',
|
||||
'flows.runs.loading': 'Memuat proses…',
|
||||
'flows.runs.loadError': 'Tidak dapat memuat proses',
|
||||
'flows.runs.empty': 'Belum ada proses',
|
||||
|
||||
'oauth.button.connecting': 'Menghubungkan...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3723,6 +3723,7 @@ const messages: TranslationMap = {
|
||||
'I flussi di lavoro salvati appariranno qui non appena ne crei uno dalla lavagna.',
|
||||
'flows.page.loading': 'Caricamento dei flussi di lavoro…',
|
||||
'flows.page.loadError': 'Impossibile caricare i flussi di lavoro. Riprova.',
|
||||
'flows.page.newWorkflow': 'Nuovo flusso di lavoro',
|
||||
'flows.list.lastRun': 'Ultima esecuzione',
|
||||
'flows.list.neverRun': 'Mai eseguito',
|
||||
'flows.list.justNow': 'Proprio ora',
|
||||
@@ -3736,6 +3737,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Abilitato',
|
||||
'flows.list.paused': 'In pausa',
|
||||
'flows.list.runStarted': 'Flusso di lavoro avviato',
|
||||
'flows.runs.title': 'Esecuzioni di {name}',
|
||||
'flows.runs.titleFallback': 'Esecuzioni del flusso di lavoro',
|
||||
'flows.runs.loading': 'Caricamento esecuzioni…',
|
||||
'flows.runs.loadError': 'Impossibile caricare le esecuzioni',
|
||||
'flows.runs.empty': 'Ancora nessuna esecuzione',
|
||||
|
||||
'oauth.button.connecting': 'Connessione...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3628,6 +3628,7 @@ const messages: TranslationMap = {
|
||||
'flows.page.emptyDescription': '캔버스에서 워크플로를 만들면 여기에 표시됩니다.',
|
||||
'flows.page.loading': '워크플로 로드 중…',
|
||||
'flows.page.loadError': '워크플로를 불러올 수 없습니다. 다시 시도해 주세요.',
|
||||
'flows.page.newWorkflow': '새 워크플로',
|
||||
'flows.list.lastRun': '마지막 실행',
|
||||
'flows.list.neverRun': '실행된 적 없음',
|
||||
'flows.list.justNow': '방금',
|
||||
@@ -3641,6 +3642,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': '활성화됨',
|
||||
'flows.list.paused': '일시 중지됨',
|
||||
'flows.list.runStarted': '워크플로가 시작되었습니다',
|
||||
'flows.runs.title': '{name}의 실행 기록',
|
||||
'flows.runs.titleFallback': '워크플로 실행 기록',
|
||||
'flows.runs.loading': '실행 기록을 불러오는 중…',
|
||||
'flows.runs.loadError': '실행 기록을 불러올 수 없습니다',
|
||||
'flows.runs.empty': '아직 실행 기록이 없습니다',
|
||||
|
||||
'oauth.button.connecting': '연결 중...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3710,6 +3710,7 @@ const messages: TranslationMap = {
|
||||
'Zapisane przepływy pracy pojawią się tutaj, gdy utworzysz jeden na płótnie.',
|
||||
'flows.page.loading': 'Ładowanie przepływów pracy…',
|
||||
'flows.page.loadError': 'Nie udało się załadować przepływów pracy. Spróbuj ponownie.',
|
||||
'flows.page.newWorkflow': 'Nowy przepływ pracy',
|
||||
'flows.list.lastRun': 'Ostatnie uruchomienie',
|
||||
'flows.list.neverRun': 'Nigdy nie uruchomiono',
|
||||
'flows.list.justNow': 'Przed chwilą',
|
||||
@@ -3723,6 +3724,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Włączony',
|
||||
'flows.list.paused': 'Wstrzymany',
|
||||
'flows.list.runStarted': 'Przepływ pracy uruchomiony',
|
||||
'flows.runs.title': 'Przebiegi dla {name}',
|
||||
'flows.runs.titleFallback': 'Przebiegi przepływu pracy',
|
||||
'flows.runs.loading': 'Ładowanie przebiegów…',
|
||||
'flows.runs.loadError': 'Nie udało się załadować przebiegów',
|
||||
'flows.runs.empty': 'Brak przebiegów',
|
||||
|
||||
'oauth.button.connecting': 'Łączenie...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3724,6 +3724,7 @@ const messages: TranslationMap = {
|
||||
'Os fluxos de trabalho salvos aparecerão aqui assim que você criar um a partir do canvas.',
|
||||
'flows.page.loading': 'Carregando fluxos de trabalho…',
|
||||
'flows.page.loadError': 'Não foi possível carregar os fluxos de trabalho. Tente novamente.',
|
||||
'flows.page.newWorkflow': 'Novo fluxo de trabalho',
|
||||
'flows.list.lastRun': 'Última execução',
|
||||
'flows.list.neverRun': 'Nunca executado',
|
||||
'flows.list.justNow': 'Agora mesmo',
|
||||
@@ -3737,6 +3738,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Habilitado',
|
||||
'flows.list.paused': 'Pausado',
|
||||
'flows.list.runStarted': 'Fluxo de trabalho iniciado',
|
||||
'flows.runs.title': 'Execuções de {name}',
|
||||
'flows.runs.titleFallback': 'Execuções do fluxo de trabalho',
|
||||
'flows.runs.loading': 'Carregando execuções…',
|
||||
'flows.runs.loadError': 'Não foi possível carregar as execuções',
|
||||
'flows.runs.empty': 'Ainda não há execuções',
|
||||
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3699,6 +3699,7 @@ const messages: TranslationMap = {
|
||||
'Сохранённые рабочие процессы появятся здесь, как только вы создадите один на холсте.',
|
||||
'flows.page.loading': 'Загрузка рабочих процессов…',
|
||||
'flows.page.loadError': 'Не удалось загрузить рабочие процессы. Попробуйте снова.',
|
||||
'flows.page.newWorkflow': 'Новый рабочий процесс',
|
||||
'flows.list.lastRun': 'Последний запуск',
|
||||
'flows.list.neverRun': 'Ещё не запускался',
|
||||
'flows.list.justNow': 'Только что',
|
||||
@@ -3712,6 +3713,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': 'Включён',
|
||||
'flows.list.paused': 'Приостановлен',
|
||||
'flows.list.runStarted': 'Рабочий процесс запущен',
|
||||
'flows.runs.title': 'Запуски для {name}',
|
||||
'flows.runs.titleFallback': 'Запуски рабочего процесса',
|
||||
'flows.runs.loading': 'Загрузка запусков…',
|
||||
'flows.runs.loadError': 'Не удалось загрузить запуски',
|
||||
'flows.runs.empty': 'Пока нет запусков',
|
||||
|
||||
'oauth.button.connecting': 'Подключение...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3474,6 +3474,7 @@ const messages: TranslationMap = {
|
||||
'flows.page.emptyDescription': '在画布中创建工作流后,将显示在此处。',
|
||||
'flows.page.loading': '正在加载工作流…',
|
||||
'flows.page.loadError': '无法加载工作流,请重试。',
|
||||
'flows.page.newWorkflow': '新建工作流',
|
||||
'flows.list.lastRun': '上次运行',
|
||||
'flows.list.neverRun': '从未运行',
|
||||
'flows.list.justNow': '刚刚',
|
||||
@@ -3487,6 +3488,11 @@ const messages: TranslationMap = {
|
||||
'flows.list.enabled': '已启用',
|
||||
'flows.list.paused': '已暂停',
|
||||
'flows.list.runStarted': '工作流已启动',
|
||||
'flows.runs.title': '{name} 的运行记录',
|
||||
'flows.runs.titleFallback': '工作流运行记录',
|
||||
'flows.runs.loading': '正在加载运行记录…',
|
||||
'flows.runs.loadError': '无法加载运行记录',
|
||||
'flows.runs.empty': '暂无运行记录',
|
||||
|
||||
'oauth.button.connecting': '连接中...',
|
||||
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* FlowsPage (issue B5a) — the Workflows list page. Asserts the
|
||||
* FlowsPage (issue B5a / B5a.1) — the Workflows list page. Asserts the
|
||||
* loading/empty/error/list states, that toggling a flow calls
|
||||
* `setFlowEnabled` and refreshes the row, and that Run fires `runFlow`,
|
||||
* shows a "Workflow started" toast, and refetches the list.
|
||||
* `setFlowEnabled` and refreshes the row, that Run fires `runFlow`, shows a
|
||||
* "Workflow started" toast, and refetches the list, that "View runs" opens
|
||||
* `FlowRunsDrawer` for the clicked flow, and that "New workflow" (header +
|
||||
* empty state) navigates to Chat (no canvas builder yet — bridges to B4's
|
||||
* agent-proposal flow).
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -14,7 +17,14 @@ import FlowsPage from './FlowsPage';
|
||||
const listFlows = vi.hoisted(() => vi.fn());
|
||||
const setFlowEnabled = vi.hoisted(() => vi.fn());
|
||||
const runFlow = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow }));
|
||||
const listFlowRuns = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow, listFlowRuns }));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
function makeFlow(overrides: Partial<Flow> = {}): Flow {
|
||||
return {
|
||||
@@ -43,13 +53,14 @@ describe('FlowsPage', () => {
|
||||
expect(screen.getByText('Loading workflows…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no saved flows', async () => {
|
||||
it('shows the empty state when there are no saved flows, with a "New workflow" action', async () => {
|
||||
listFlows.mockResolvedValue([]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('No workflows yet')).toBeInTheDocument());
|
||||
// The empty state omits a "Create" action (canvas ships in B5b).
|
||||
expect(screen.queryByRole('button', { name: /create/i })).not.toBeInTheDocument();
|
||||
// There's no canvas builder yet (B5b) — the empty state's action bridges
|
||||
// to Chat/B4 instead, same as the header button.
|
||||
expect(screen.getByTestId('flows-empty-new-workflow')).toHaveTextContent('New workflow');
|
||||
});
|
||||
|
||||
it('shows an error banner when the fetch fails', async () => {
|
||||
@@ -108,4 +119,41 @@ describe('FlowsPage', () => {
|
||||
await waitFor(() => expect(screen.getByText('flow disabled')).toBeInTheDocument());
|
||||
expect(screen.queryByText('Workflow started')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the run-history drawer for the clicked flow when "View runs" is clicked', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow()]);
|
||||
listFlowRuns.mockResolvedValue([]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('flow-view-runs-flow-1')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('flow-view-runs-flow-1'));
|
||||
|
||||
expect(await screen.findByTestId('flow-runs-drawer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Runs for Daily digest')).toBeInTheDocument();
|
||||
expect(listFlowRuns).toHaveBeenCalledWith('flow-1');
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-runs-close'));
|
||||
expect(screen.queryByTestId('flow-runs-drawer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a "New workflow" header button and navigates to /chat when clicked', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow()]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
const newWorkflowButton = await screen.findByTestId('flows-new-workflow');
|
||||
expect(newWorkflowButton).toHaveTextContent('New workflow');
|
||||
fireEvent.click(newWorkflowButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/chat');
|
||||
});
|
||||
|
||||
it('navigates to /chat when the empty-state "New workflow" action is clicked', async () => {
|
||||
listFlows.mockResolvedValue([]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
const emptyStateButton = await screen.findByTestId('flows-empty-new-workflow');
|
||||
fireEvent.click(emptyStateButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/chat');
|
||||
});
|
||||
});
|
||||
|
||||
+58
-19
@@ -3,17 +3,20 @@
|
||||
*
|
||||
* The discoverable hub for the `flows::` domain: lists every saved
|
||||
* `Flow` (name, enabled toggle, last-run status, Run button). This is NOT the
|
||||
* canvas (B5b ships flow authoring/editing) and NOT the chat agent-proposal
|
||||
* surface (B4) — just the top-level `/flows` list, reached via the
|
||||
* "Workflows" nav tab (see `config/navConfig.ts`).
|
||||
* canvas (B5b ships flow authoring/editing) — until it lands, "New workflow"
|
||||
* (header + empty-state) bridges to the B4 agent-proposal flow in Chat
|
||||
* instead, since that's the only way to author a flow today.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import EmptyStateCard from '../components/EmptyStateCard';
|
||||
import FlowListRow, { type FlowListRowBusy } from '../components/flows/FlowListRow';
|
||||
import FlowRunsDrawer from '../components/flows/FlowRunsDrawer';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import Button from '../components/ui/Button';
|
||||
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { type Flow, listFlows, runFlow, setFlowEnabled } from '../services/api/flowsApi';
|
||||
@@ -30,11 +33,16 @@ function errorMessage(err: unknown): string {
|
||||
|
||||
export default function FlowsPage() {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const [flows, setFlows] = useState<Flow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<BusyKey | null>(null);
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
// Flow whose run history is open in `FlowRunsDrawer` (B3b's run inspector
|
||||
// then stacks on top of that when a specific run is picked). `null` keeps
|
||||
// the drawer unmounted.
|
||||
const [selectedFlowId, setSelectedFlowId] = useState<string | null>(null);
|
||||
|
||||
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
||||
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
|
||||
@@ -116,11 +124,47 @@ export default function FlowsPage() {
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleViewRuns = useCallback((flow: Flow) => {
|
||||
log('view runs: id=%s', flow.id);
|
||||
setSelectedFlowId(flow.id);
|
||||
}, []);
|
||||
|
||||
const selectedFlow = flows.find(f => f.id === selectedFlowId) ?? null;
|
||||
|
||||
/**
|
||||
* "New workflow" (there's no canvas builder yet — B5b) bridges to Chat so
|
||||
* the user can kick off B4's agent-proposal flow instead. There's no
|
||||
* existing mechanism to prefill or auto-send an initial composer message
|
||||
* from outside the Chat page — `Conversations.tsx` only reads
|
||||
* `location.state.openThreadId` (to reopen a thread), and the composer's
|
||||
* text is local `useState` with no Redux draft slice. This is the same gap
|
||||
* `ActionItemChecklist.tsx`'s "Run with OpenHuman" button already hit, so
|
||||
* we follow its precedent: navigate to `/chat` with no prefill rather than
|
||||
* build new prefill plumbing from scratch.
|
||||
*/
|
||||
const handleNewWorkflow = useCallback(() => {
|
||||
log('new workflow: navigating to chat');
|
||||
// TODO: prefill the chat composer with a workflow-building prompt once a
|
||||
// draft/initial-message API exists (see ActionItemChecklist.tsx's
|
||||
// identical TODO for the same gap).
|
||||
navigate('/chat');
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<PanelPage
|
||||
testId="flows-page"
|
||||
title={t('flows.page.title')}
|
||||
description={t('flows.page.description')}>
|
||||
description={t('flows.page.description')}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="flows-new-workflow"
|
||||
onClick={handleNewWorkflow}>
|
||||
{t('flows.page.newWorkflow')}
|
||||
</Button>
|
||||
}>
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
{error && (
|
||||
<div data-testid="flows-error">
|
||||
@@ -147,6 +191,9 @@ export default function FlowsPage() {
|
||||
}
|
||||
title={t('flows.page.emptyTitle')}
|
||||
description={t('flows.page.emptyDescription')}
|
||||
actionLabel={t('flows.page.newWorkflow')}
|
||||
actionTestId="flows-empty-new-workflow"
|
||||
onAction={handleNewWorkflow}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -161,27 +208,19 @@ export default function FlowsPage() {
|
||||
busy={busyFor(flow)}
|
||||
onToggle={f => void handleToggle(f)}
|
||||
onRun={f => void handleRun(f)}
|
||||
onViewRuns={handleViewRuns}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* === B3b integration (wire after PR #4450 merges) ===
|
||||
"View runs" was pulled from `FlowListRow` for now — it would only
|
||||
store a `selectedFlowId` with nothing to show for it until the run
|
||||
inspector lands, which reads as a dead button. Once #4450 merges,
|
||||
re-add here as: track `selectedFlowId` state, list the flow's runs
|
||||
via listFlowRuns(flowId), and open the inspector
|
||||
(FlowRunInspectorDrawer, keyed by RUN id / thread_id, NOT flowId)
|
||||
for a chosen run:
|
||||
{selectedFlowId && (
|
||||
<FlowRunInspectorRunsForFlow
|
||||
flowId={selectedFlowId}
|
||||
onClose={() => setSelectedFlowId(null)}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
<FlowRunsDrawer
|
||||
flowId={selectedFlowId}
|
||||
flowName={selectedFlow?.name}
|
||||
onClose={() => setSelectedFlowId(null)}
|
||||
/>
|
||||
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
</PanelPage>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user