mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(web): focus newly created automations after save (#3035)
Expand and briefly highlight the saved routine row so users can review it immediately. Extract newest-first sort helper and add regression tests for list ordering and post-create focus.
This commit is contained in:
parent
8264a7c4b1
commit
17c78f64a3
3 changed files with 242 additions and 5 deletions
|
|
@ -2,7 +2,7 @@
|
|||
// and live artifact refreshers. The daemon still stores these as routines;
|
||||
// the UI presents them as scheduled agent conversations.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
AutomationContentPacket,
|
||||
AutomationEvolutionProposal,
|
||||
|
|
@ -411,6 +411,8 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
|
|||
const [ingestingSource, setIngestingSource] = useState(false);
|
||||
const [crystallizingRunId, setCrystallizingRunId] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [focusRoutineId, setFocusRoutineId] = useState<string | null>(null);
|
||||
const routineRowRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
||||
const [historyTick, setHistoryTick] = useState(0);
|
||||
|
||||
const templates = useMemo(
|
||||
|
|
@ -490,10 +492,18 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
|
|||
|
||||
// Sort routines by creation time, newest first
|
||||
const sortedRoutines = useMemo(
|
||||
() => [...routines].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
|
||||
() => sortRoutinesNewestFirst(routines),
|
||||
[routines],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRoutineId) return;
|
||||
const node = routineRowRefs.current[focusRoutineId];
|
||||
node?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
const timer = window.setTimeout(() => setFocusRoutineId(null), 4000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [focusRoutineId, sortedRoutines]);
|
||||
|
||||
const activeCount = sortedRoutines.filter((routine) => routine.enabled).length;
|
||||
const pausedCount = sortedRoutines.length - activeCount;
|
||||
const sourceIngestionTemplates = useMemo(
|
||||
|
|
@ -730,7 +740,11 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
|
|||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className={`automation-row${r.enabled ? '' : ' is-paused'}`}
|
||||
ref={(node) => {
|
||||
routineRowRefs.current[r.id] = node;
|
||||
}}
|
||||
data-testid={`automation-row-${r.id}`}
|
||||
className={`automation-row${r.enabled ? '' : ' is-paused'}${focusRoutineId === r.id ? ' is-focused' : ''}`}
|
||||
>
|
||||
<div className="automation-row__main">
|
||||
<span className="automation-row__icon">
|
||||
|
|
@ -1124,14 +1138,22 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
|
|||
skills={skills}
|
||||
connectors={connectors}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={() => {
|
||||
void refresh();
|
||||
onSaved={(routine) => {
|
||||
void (async () => {
|
||||
await refresh();
|
||||
setExpandedId(routine.id);
|
||||
setFocusRoutineId(routine.id);
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function sortRoutinesNewestFirst(routines: Routine[]): Routine[] {
|
||||
return [...routines].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="automations-metric">
|
||||
|
|
|
|||
|
|
@ -287,6 +287,13 @@
|
|||
opacity: 0.74;
|
||||
}
|
||||
|
||||
.automation-row.is-focused {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent),
|
||||
var(--shadow-xs);
|
||||
}
|
||||
|
||||
.automation-row__main {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
|
|
|
|||
208
apps/web/tests/components/TasksView.routines.test.tsx
Normal file
208
apps/web/tests/components/TasksView.routines.test.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Routine } from '@open-design/contracts';
|
||||
|
||||
import { sortRoutinesNewestFirst, TasksView } from '../../src/components/TasksView';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function makeRoutine(overrides: Partial<Routine> & Pick<Routine, 'id' | 'name'>): Routine {
|
||||
return {
|
||||
prompt: 'Run scheduled work.',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
skillId: null,
|
||||
agentId: null,
|
||||
enabled: true,
|
||||
nextRunAt: null,
|
||||
lastRun: null,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../../src/components/NewAutomationModal', () => ({
|
||||
NewAutomationModal: ({
|
||||
open,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onSaved: (routine: Routine) => void;
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-save-routine"
|
||||
onClick={() =>
|
||||
onSaved(
|
||||
makeRoutine({
|
||||
id: 'routine-new',
|
||||
name: 'Fresh automation',
|
||||
createdAt: 9000,
|
||||
updatedAt: 9000,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
Mock save
|
||||
</button>
|
||||
);
|
||||
},
|
||||
describeScheduleSummary: () => 'Daily at 9:00',
|
||||
}));
|
||||
|
||||
function mockTasksFetch(routines: Routine[]) {
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/automation-templates') {
|
||||
return new Response(JSON.stringify({ templates: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/automation-proposals?status=pending-review') {
|
||||
return new Response(JSON.stringify({ proposals: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/automation-source-packets?limit=3') {
|
||||
return new Response(JSON.stringify({ packets: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-new/runs?limit=10') {
|
||||
return new Response(JSON.stringify({ runs: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
describe('TasksView routine ordering and focus', () => {
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sortRoutinesNewestFirst orders by createdAt descending', () => {
|
||||
const older = makeRoutine({ id: 'older', name: 'Older', createdAt: 1000 });
|
||||
const newer = makeRoutine({ id: 'newer', name: 'Newer', createdAt: 5000 });
|
||||
|
||||
expect(sortRoutinesNewestFirst([older, newer]).map((routine) => routine.id)).toEqual([
|
||||
'newer',
|
||||
'older',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders saved automations newest first', async () => {
|
||||
mockTasksFetch([
|
||||
makeRoutine({ id: 'older', name: 'Older automation', createdAt: 1000 }),
|
||||
makeRoutine({ id: 'newer', name: 'Newer automation', createdAt: 5000 }),
|
||||
]);
|
||||
|
||||
render(<TasksView />);
|
||||
|
||||
const rows = await screen.findAllByRole('listitem');
|
||||
expect(rows[0]?.textContent).toContain('Newer automation');
|
||||
expect(rows[1]?.textContent).toContain('Older automation');
|
||||
});
|
||||
|
||||
it('focuses and expands a routine after create save', async () => {
|
||||
const existingRoutine = makeRoutine({
|
||||
id: 'routine-existing',
|
||||
name: 'Existing automation',
|
||||
createdAt: 1000,
|
||||
});
|
||||
const savedRoutine = makeRoutine({
|
||||
id: 'routine-new',
|
||||
name: 'Fresh automation',
|
||||
createdAt: 9000,
|
||||
updatedAt: 9000,
|
||||
});
|
||||
let routinesFetchCount = 0;
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
routinesFetchCount += 1;
|
||||
const routines =
|
||||
routinesFetchCount >= 2 ? [savedRoutine, existingRoutine] : [existingRoutine];
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/automation-templates') {
|
||||
return new Response(JSON.stringify({ templates: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/automation-proposals?status=pending-review') {
|
||||
return new Response(JSON.stringify({ proposals: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/automation-source-packets?limit=3') {
|
||||
return new Response(JSON.stringify({ packets: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-new/runs?limit=10') {
|
||||
return new Response(JSON.stringify({ runs: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<TasksView />);
|
||||
await screen.findByText('Existing automation');
|
||||
|
||||
fireEvent.click(screen.getByTestId('automations-new'));
|
||||
fireEvent.click(screen.getByTestId('mock-save-routine'));
|
||||
|
||||
const focusedRow = await waitFor(() => {
|
||||
const row = screen.getByTestId('automation-row-routine-new');
|
||||
expect(row.className).toContain('is-focused');
|
||||
return row;
|
||||
});
|
||||
expect(focusedRow.textContent).toContain('Fresh automation');
|
||||
expect(screen.getByRole('button', { name: 'Hide history' })).toBeTruthy();
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue