Files
Metamorph/frontend/src/pages/MissionsListPage.tsx

168 lines
5.6 KiB
TypeScript
Raw Normal View History

feat(m6): missions + snapshot CRUD, membership visibility, status state machine Adds the mission layer that materialises template snapshots, plus the SPA list / 3-step wizard / detail page. Backend: - app/services/missions.py — create_mission snapshots scenarios, tests, MITRE tags in a 4-query write; list/get apply a non-admin membership filter that collapses to 404 (no existence leak); status state machine enforces draft → in_progress → completed → archived with archived as a sink; the non-admin creator is auto-added as role_hint='red' to retain visibility. - app/api/missions.py — 8 endpoints (list, get, create, update, add scenarios, set members, transition, soft-delete) with strict pydantic schemas. The transition endpoint splits the perm gate manually so archive requires mission.archive while other targets use mission.update. - app/api/users.py — new GET /users/roster returning (id, email, display_name) only, gated by user.read OR mission.create OR mission.update — lets non-admin wizard users see assignable peers without exposing the admin /users payload. - app/api/diag.py — /diag/reset truncates the mission_* tables before the template tables because the source_*_template_id FKs are ON DELETE SET NULL, which is cheaper to short-circuit by removing the children first. Frontend: - lib/missions.ts — typed client, queryKey factory, status accent map. - pages/MissionsListPage.tsx — list cards with status accent + filters (q, client, status). - pages/MissionsCreatePage.tsx — 3-step wizard (meta → scenarios → members) with member roster fed by /users/roster. - pages/MissionDetailPage.tsx — header + transition buttons (legal next states only) + Tests/Members/Synthesis/Export tabs. - Routes + nav entry (visible to anyone with mission.read or admin). Tests: - backend/tests/test_missions.py — 22 pytest covering snapshot fidelity, MITRE propagation, membership visibility, transition state machine, perm gating, member set replace, append scenarios, soft-delete, partial update, inverted-date rejection. - e2e/tests/m6-missions.spec.ts — 5 Playwright (snapshot freezing, non-admin visibility, status transitions + 409, SPA wizard end-to-end, list filter). Docs: - CHANGELOG, tasks/testing-m6.md, tasks/lessons.md (snapshot tradeoffs, membership=404 pattern, /diag/reset order, auto-creator add). - README + tasks/todo.md updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:07:32 +02:00
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import {
MISSION_STATUS_ACCENT,
MISSION_STATUS_LABEL,
buildMissionQueryString,
missionKeys,
type MissionFilters,
type MissionListResponse,
type MissionStatus,
} from '@/lib/missions';
const STATUS_OPTIONS: Array<{ value: '' | MissionStatus; label: string }> = [
{ value: '', label: 'All statuses' },
{ value: 'draft', label: MISSION_STATUS_LABEL.draft },
{ value: 'in_progress', label: MISSION_STATUS_LABEL.in_progress },
{ value: 'completed', label: MISSION_STATUS_LABEL.completed },
{ value: 'archived', label: MISSION_STATUS_LABEL.archived },
];
function useMissions(filters: MissionFilters) {
return useQuery({
queryKey: missionKeys.list(filters),
queryFn: () =>
apiGet<MissionListResponse>(`/missions${buildMissionQueryString(filters)}`),
});
}
function formatDateRange(start: string | null, end: string | null): string {
if (!start && !end) return '—';
if (start && end) return `${start}${end}`;
return start ?? end ?? '—';
}
export function MissionsListPage() {
const { state } = useAuth();
const canCreate =
state.user?.is_admin || state.user?.permissions.includes('mission.create');
const [q, setQ] = useState('');
const [status, setStatus] = useState<'' | MissionStatus>('');
const [client, setClient] = useState('');
const filters = useMemo<MissionFilters>(
() => ({
q: q.trim() || undefined,
status: status || undefined,
client: client.trim() || undefined,
}),
[q, status, client],
);
const { data, error, isLoading } = useMissions(filters);
const apiErr = error instanceof ApiError ? error : null;
return (
<section data-testid="missions-list">
<div className="flex items-baseline justify-between">
<SectionHeader prefix="Plan" highlight="Missions" accent="cyan" />
{canCreate && (
<Link to="/missions/new" data-testid="missions-new-link">
<Button accent="cyan">+ New mission</Button>
</Link>
)}
</div>
<Card className="mb-6">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<TextField
label="Search"
placeholder="name or description"
value={q}
onChange={(e) => setQ(e.target.value)}
data-testid="missions-filter-q"
/>
<TextField
label="Client"
placeholder="acme corp"
value={client}
onChange={(e) => setClient(e.target.value)}
data-testid="missions-filter-client"
/>
<div className="flex flex-col gap-1">
<label className="font-mono text-2xs uppercase tracking-wider2 text-text-dim">
Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as '' | MissionStatus)}
data-testid="missions-filter-status"
className="bg-bg-card border border-border rounded px-3 py-2 font-mono text-xs text-text-bright"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</Card>
{apiErr && (
<div data-testid="missions-error">
<Alert accent="red">{apiErr.message}</Alert>
</div>
)}
{isLoading && (
<p className="font-mono text-xs text-text-dim">Loading missions</p>
)}
{data && data.items.length === 0 && !isLoading && (
<Card>
<p className="font-mono text-xs text-text-dim" data-testid="missions-empty">
No missions match the filters. {canCreate ? 'Create one to get started.' : ''}
</p>
</Card>
)}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2" data-testid="missions-grid">
{data?.items.map((m) => {
const accent = MISSION_STATUS_ACCENT[m.status];
return (
<Link key={m.id} to={`/missions/${m.id}`} data-testid={`mission-card-${m.id}`}>
<Card
accent={accent}
title={m.name}
sub={m.client_target ?? 'No client'}
className="h-full"
>
<div className="flex flex-wrap items-center gap-2">
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
<Tag accent="cyan">{m.scenarios_count} scenarios</Tag>
<Tag accent="purple">{m.tests_count} tests</Tag>
<Tag accent="teal">{m.members_count} members</Tag>
</div>
<p className="mt-3 font-mono text-2xs text-text-dim">
{formatDateRange(m.date_start, m.date_end)}
</p>
</Card>
</Link>
);
})}
</div>
{data && (
<p
className="mt-4 font-mono text-2xs text-text-dim"
data-testid="missions-total"
>
{data.total} mission{data.total === 1 ? '' : 's'} total
</p>
)}
</section>
);
}