diff --git a/CHANGELOG.md b/CHANGELOG.md index f645e49..3f653f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented here. Format: [Keep a Cha ## [Unreleased] +### Fixed (post-M6 SPA — mission detail page was read-only) +- **Mission detail page couldn't edit metadata, append scenarios, or change members** (`frontend/src/pages/MissionDetailPage.tsx`): the M6 SPA shipped the 3-step *creation* wizard but no edit affordance on the detail page — even though the backend already exposed `PUT /missions/{id}`, `POST /missions/{id}/scenarios`, and `PUT /missions/{id}/members`. Added three modals gated by `is_admin || mission.update`: + - **Edit metadata** (header button, opens a 3xl modal): name / client_target / dates / description_md, full inline validation (empty name, inverted dates) mirroring the wizard's step 1. + - **Add scenarios** (in the Tests tab): scenario picker reusing the wizard step-2 visual, calls `POST /missions/{id}/scenarios` which appends snapshots at `current_max_position + 1`. The footer line tells the user how many tests will be appended. + - **Edit members** (in the Members tab): roster + red/blue toggles, calls `PUT /missions/{id}/members` (full-set replace) — same UX as the wizard step 3, pre-populated with the current member set. +- Detail page now imports `useAuth` to compute `canEdit` once and reuses it across all three buttons. +- E2E spec extended: new test `SPA — detail page edits metadata, appends scenarios, edits members` exercises the three modals end-to-end against a pre-seeded mission. Suite is now 44 Playwright tests (6 in M6). + ### Fixed (post-M6 review pass — spec-reviewer + code-reviewer) - **SPA cache invalidation only refreshed the empty-filter list** (`frontend/src/lib/missions.ts:136`): `missionKeys.list()` returns `['missions','list',{}]`. TanStack v5's `invalidateQueries({queryKey})` is prefix-based, but `{}` is treated as an atomic final element — so create / transition / delete called with that key only invalidated the *exact* empty-filter list, leaving any filtered variant stale until manual refetch. Added `missionKeys.listPrefix()` returning `['missions','list']` and switched all three mutation `onSuccess` paths to it. - **Snapshot lacked the per-scenario advisory lock** (`backend/app/services/missions.py:467`): a concurrent `PUT /scenario-templates/{id}/tests` (M5 reorder, which deletes-then-reinserts join rows) running while `_snapshot_scenarios` walked `sc.tests` could freeze a torn snapshot — `selectinload` re-queries under READ COMMITTED so a partial view was possible. Added `_lock_scenario_ids_for_snapshot` that acquires the same `pg_advisory_xact_lock` key used by `set_scenario_tests` (blake2b digest of the scenario UUID, sorted to avoid deadlocks). Snapshot and reorder now serialise per scenario. diff --git a/e2e/tests/m6-missions.spec.ts b/e2e/tests/m6-missions.spec.ts index c6cbc1e..fad925b 100644 --- a/e2e/tests/m6-missions.spec.ts +++ b/e2e/tests/m6-missions.spec.ts @@ -292,6 +292,90 @@ test.describe('M6 — Missions', () => { await expect(page.getByText('spa-wizard-t3')).toBeVisible(); }); + test('SPA — detail page edits metadata, appends scenarios, edits members', async ({ + page, + request, + }) => { + const auth = await adminAuth(request); + + // Pre-seed: one mission with one initial scenario; a second scenario to + // append; and a second user we can assign as a member from the SPA. + const initialTestId = await makeTest(request, auth, 'spa-edit-initial-t'); + const initialScenarioId = await makeScenario( + request, + auth, + 'spa-edit-initial-scenario', + [initialTestId], + ); + const extraTestId = await makeTest(request, auth, 'spa-edit-appended-t'); + const extraScenarioId = await makeScenario( + request, + auth, + 'spa-edit-appended-scenario', + [extraTestId], + ); + const mission = await request + .post('/api/v1/missions', { + headers: auth, + data: { + name: 'spa-edit-target', + client_target: 'Initial Co.', + scenario_template_ids: [initialScenarioId], + }, + }) + .then((r) => r.json()); + + // A second user the admin can add as a member via the modal. + const teammateEmail = `spa-edit-mate-${crypto.randomUUID().slice(0, 8)}@metamorph.local`; + const inv = await request + .post('/api/v1/invitations', { + headers: auth, + data: { email_hint: teammateEmail }, + }) + .then((r) => r.json()); + await request.post(`/api/v1/invitations/accept/${inv.token}`, { + data: { email: teammateEmail, password: 'MatePass1234!' }, + }); + + await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto(`/missions/${mission.id}`); + await expect(page.getByText('Initial Co.')).toBeVisible(); + + // --- Edit metadata -------------------------------------------------- + await page.getByTestId('mission-edit-meta').click(); + const metaModal = page.getByTestId('mission-edit-meta-modal'); + await expect(metaModal).toBeVisible(); + await metaModal.getByTestId('meta-edit-client').fill('Renamed Co.'); + await metaModal.getByTestId('meta-edit-save').click(); + await expect(metaModal).toBeHidden(); + await expect(page.getByText('Renamed Co.')).toBeVisible(); + + // --- Append a scenario --------------------------------------------- + await page.getByTestId('mission-add-scenarios').click(); + const addModal = page.getByTestId('mission-add-scenarios-modal'); + await expect(addModal).toBeVisible(); + await addModal.getByTestId(`add-scenario-toggle-${extraScenarioId}`).click(); + await addModal.getByTestId('add-scenarios-save').click(); + await expect(addModal).toBeHidden(); + // Both scenarios now visible in the Tests tab + await expect(page.getByText('spa-edit-initial-scenario')).toBeVisible(); + await expect(page.getByText('spa-edit-appended-scenario')).toBeVisible(); + await expect(page.getByText('spa-edit-appended-t')).toBeVisible(); + + // --- Edit members --------------------------------------------------- + await page.getByTestId('mission-tab-members').click(); + await page.getByTestId('mission-edit-members').click(); + const memModal = page.getByTestId('mission-edit-members-modal'); + await expect(memModal).toBeVisible(); + // The roster row test-ids encode the new user's id; we don't know it here + // but the email is unique, so locate the row by email text and toggle red. + const teammateRow = memModal.getByText(teammateEmail).locator('..').locator('..'); + await teammateRow.getByRole('button', { name: /red/i }).click(); + await memModal.getByTestId('edit-members-save').click(); + await expect(memModal).toBeHidden(); + await expect(page.getByText(teammateEmail)).toBeVisible(); + }); + test('SPA — list page filters by status', async ({ page, request }) => { const auth = await adminAuth(request); // Seed two missions with distinct statuses. diff --git a/frontend/src/pages/MissionDetailPage.tsx b/frontend/src/pages/MissionDetailPage.tsx index 30e3392..ce50856 100644 --- a/frontend/src/pages/MissionDetailPage.tsx +++ b/frontend/src/pages/MissionDetailPage.tsx @@ -1,21 +1,35 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { MarkdownField } from '@/components/MarkdownField'; import { Alert } from '@/components/ui/Alert'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; +import { Modal } from '@/components/ui/Modal'; import { SectionHeader } from '@/components/ui/SectionHeader'; import { Tag } from '@/components/ui/Tag'; -import { ApiError, apiDelete, apiGet, apiPost } from '@/lib/api'; +import { TextField } from '@/components/ui/TextField'; +import { ApiError, apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; import { MISSION_STATUS_ACCENT, MISSION_STATUS_LABEL, missionKeys, + type AddScenariosPayload, + type MemberPayload, type Mission, + type MissionRoleHint, type MissionStatus, + type SetMembersPayload, type TransitionPayload, + type UpdateMissionPayload, } from '@/lib/missions'; +import type { + ScenarioTemplate, + ScenarioTemplateListResponse, +} from '@/lib/templates'; +import { templateKeys } from '@/lib/templates'; const TABS = ['tests', 'members', 'synthesis', 'export'] as const; type Tab = (typeof TABS)[number]; @@ -34,6 +48,21 @@ const TRANSITION_BUTTON_ACCENT: Record + apiGet('/scenario-templates?limit=500'), + enabled, + }); +} + +function useRoster(enabled: boolean) { + return useQuery({ + queryKey: ['users', 'roster'], + queryFn: () => apiGet('/users/roster'), + enabled, + }); +} + function formatDateRange(start: string | null, end: string | null): string { if (!start && !end) return 'No dates set'; if (start && end) return `${start} → ${end}`; return start ?? end ?? ''; } +// --------------------------------------------------------------------------- // +// Metadata edit modal // +// --------------------------------------------------------------------------- // + +interface MetaEditModalProps { + mission: Mission; + open: boolean; + onClose: () => void; +} + +function MetaEditModal({ mission, open, onClose }: MetaEditModalProps) { + const qc = useQueryClient(); + const [name, setName] = useState(mission.name); + const [client, setClient] = useState(mission.client_target ?? ''); + const [dateStart, setDateStart] = useState(mission.date_start ?? ''); + const [dateEnd, setDateEnd] = useState(mission.date_end ?? ''); + const [description, setDescription] = useState(mission.description_md ?? ''); + + // Reset form whenever the modal opens with a (potentially newer) mission. + useEffect(() => { + if (!open) return; + setName(mission.name); + setClient(mission.client_target ?? ''); + setDateStart(mission.date_start ?? ''); + setDateEnd(mission.date_end ?? ''); + setDescription(mission.description_md ?? ''); + }, [open, mission]); + + const update = useMutation({ + mutationFn: (body: UpdateMissionPayload) => + apiPut(`/missions/${mission.id}`, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) }); + qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); + onClose(); + }, + }); + + const apiErr = update.error instanceof ApiError ? update.error : null; + const nameInvalid = name.trim().length === 0; + const datesInvalid = dateStart && dateEnd && dateEnd < dateStart; + + function submit() { + update.mutate({ + name: name.trim(), + client_target: client.trim() || null, + date_start: dateStart || null, + date_end: dateEnd || null, + description_md: description.trim() || null, + }); + } + + return ( + +
+ {apiErr && {apiErr.message}} +
+ setName(e.target.value)} + data-testid="meta-edit-name" + /> + setClient(e.target.value)} + data-testid="meta-edit-client" + /> + setDateStart(e.target.value)} + data-testid="meta-edit-date-start" + /> + setDateEnd(e.target.value)} + data-testid="meta-edit-date-end" + /> +
+ + {datesInvalid && ( +

+ End date must be on or after start date. +

+ )} +
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- // +// Add-scenarios modal // +// --------------------------------------------------------------------------- // + +interface AddScenariosModalProps { + mission: Mission; + open: boolean; + onClose: () => void; +} + +function AddScenariosModal({ mission, open, onClose }: AddScenariosModalProps) { + const qc = useQueryClient(); + const [selected, setSelected] = useState([]); + const catalogue = useScenarioCatalogue(open); + + useEffect(() => { + if (open) setSelected([]); + }, [open]); + + const add = useMutation({ + mutationFn: (body: AddScenariosPayload) => + apiPost(`/missions/${mission.id}/scenarios`, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) }); + qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); + onClose(); + }, + }); + + const apiErr = add.error instanceof ApiError ? add.error : null; + + function toggle(id: string) { + setSelected((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + } + + function submit() { + add.mutate({ scenario_template_ids: selected }); + } + + const totalTestsToAdd = useMemo(() => { + if (!catalogue.data) return 0; + const by_id = new Map( + catalogue.data.items.map((sc) => [sc.id, sc] as const), + ); + return selected.reduce((acc, id) => acc + (by_id.get(id)?.tests_count ?? 0), 0); + }, [selected, catalogue.data]); + + return ( + +
+ {apiErr && {apiErr.message}} + {catalogue.isError && Failed to load scenarios.} + {catalogue.isLoading && ( +

Loading…

+ )} +

+ {selected.length} scenario{selected.length === 1 ? '' : 's'} ·{' '} + {totalTestsToAdd} test{totalTestsToAdd === 1 ? '' : 's'} will be appended + after the current {mission.scenarios_count}. +

+
    + {catalogue.data?.items.map((sc) => { + const isSelected = selected.includes(sc.id); + return ( +
  • + +
  • + ); + })} +
+ {catalogue.data && catalogue.data.items.length === 0 && ( +

+ No scenarios in the catalogue yet. +

+ )} +
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- // +// Edit-members modal // +// --------------------------------------------------------------------------- // + +interface EditMembersModalProps { + mission: Mission; + open: boolean; + onClose: () => void; +} + +function EditMembersModal({ mission, open, onClose }: EditMembersModalProps) { + const qc = useQueryClient(); + const roster = useRoster(open); + const [members, setMembers] = useState([]); + + useEffect(() => { + if (!open) return; + setMembers( + mission.members.map((m) => ({ + user_id: m.user_id, + role_hint: m.role_hint, + })), + ); + }, [open, mission]); + + const save = useMutation({ + mutationFn: (body: SetMembersPayload) => + apiPut(`/missions/${mission.id}/members`, body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: missionKeys.detail(mission.id) }); + qc.invalidateQueries({ queryKey: missionKeys.listPrefix() }); + onClose(); + }, + }); + + const apiErr = save.error instanceof ApiError ? save.error : null; + + function setRole(user_id: string, role_hint: MissionRoleHint) { + setMembers((prev) => + prev.some((m) => m.user_id === user_id) + ? prev.map((m) => (m.user_id === user_id ? { ...m, role_hint } : m)) + : [...prev, { user_id, role_hint }], + ); + } + + function remove(user_id: string) { + setMembers((prev) => prev.filter((m) => m.user_id !== user_id)); + } + + function submit() { + const payload: SetMembersPayload = { + members: members.map( + (m): MemberPayload => ({ user_id: m.user_id, role_hint: m.role_hint }), + ), + }; + save.mutate(payload); + } + + return ( + +
+ {apiErr && {apiErr.message}} + {roster.isError && Failed to load roster.} + {roster.isLoading && ( +

Loading users…

+ )} +
    + {roster.data?.items.map((u) => { + const selected = members.find((m) => m.user_id === u.id); + return ( +
  • +
    +

    + {u.display_name ?? u.email} +

    + {u.display_name && ( +

    {u.email}

    + )} +
    +
    + + + {selected && ( + + )} +
    +
  • + ); + })} +
+
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- // +// Main page // +// --------------------------------------------------------------------------- // + export function MissionDetailPage() { const params = useParams(); const missionId = params.id ?? ''; const navigate = useNavigate(); const qc = useQueryClient(); + const { state } = useAuth(); + + const canEdit = + state.user?.is_admin || + state.user?.permissions.includes('mission.update') || + false; const [tab, setTab] = useState('tests'); + const [editMeta, setEditMeta] = useState(false); + const [addScenarios, setAddScenarios] = useState(false); + const [editMembers, setEditMembers] = useState(false); const detail = useMission(missionId); const transition = useMutation({ @@ -98,6 +539,16 @@ export function MissionDetailPage() {
{MISSION_STATUS_LABEL[m.status]} + {canEdit && ( + + )} {allowedNext.map((target) => ( + )} +
{m.scenarios.length === 0 ? (

No scenarios snapshotted yet. + {canEdit && ' Click "Add scenarios" to append one.'}

) : (
@@ -260,9 +727,25 @@ export function MissionDetailPage() { {tab === 'members' && ( +
+

+ Members see this mission and (for reds) can author red-side fields + on its tests in M7+. +

+ {canEdit && ( + + )} +
{m.members.length === 0 ? (

- No members assigned. An admin can add them via the API. + No members assigned. + {canEdit && ' Click "Edit members" to add some.'}

) : (
    @@ -307,6 +790,18 @@ export function MissionDetailPage() {

    )} + + setEditMeta(false)} /> + setAddScenarios(false)} + /> + setEditMembers(false)} + /> ); }