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>
This commit is contained in:
167
frontend/src/pages/MissionsListPage.tsx
Normal file
167
frontend/src/pages/MissionsListPage.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user