fix(m6): mission detail page can now edit metadata, append scenarios, edit members
The M6 SPA shipped the create wizard but the detail page was read-only —
even though the backend already exposed PUT /missions/{id}, POST
/missions/{id}/scenarios, and PUT /missions/{id}/members. So once a
mission was created you couldn't fix a typo in the client name, add a
scenario you forgot, or change member assignments without curl.
Added three modals on the detail page, gated by `is_admin ||
mission.update`:
- Edit metadata (header button, 3xl modal): name + client + dates +
markdown description, same validation as the wizard step 1.
- Add scenarios (Tests tab): scenario picker matching wizard step 2,
calls POST /missions/{id}/scenarios which appends snapshots at
current_max_position + 1.
- Edit members (Members tab): roster + red/blue toggles, calls
PUT /missions/{id}/members (full-set replace), pre-populated with
the current member set.
The detail page now imports useAuth so `canEdit` is computed once and
shared between the three buttons.
E2E: new "detail page edits metadata, appends scenarios, edits members"
spec exercises the three modals end-to-end. M6 e2e count is now 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<MissionStatus, 'cyan' | 'orange' | 'green
|
||||
archived: 'teal',
|
||||
};
|
||||
|
||||
interface RosterUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
interface RosterResponse {
|
||||
items: RosterUser[];
|
||||
}
|
||||
|
||||
interface MemberSelection {
|
||||
user_id: string;
|
||||
role_hint: MissionRoleHint;
|
||||
}
|
||||
|
||||
function useMission(id: string) {
|
||||
return useQuery({
|
||||
queryKey: missionKeys.detail(id),
|
||||
@@ -42,19 +71,431 @@ function useMission(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function useScenarioCatalogue(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.scenarios(''),
|
||||
queryFn: () =>
|
||||
apiGet<ScenarioTemplateListResponse>('/scenario-templates?limit=500'),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useRoster(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'roster'],
|
||||
queryFn: () => apiGet<RosterResponse>('/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<Mission>(`/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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={mission.name}
|
||||
accent="cyan"
|
||||
size="3xl"
|
||||
testid="mission-edit-meta-modal"
|
||||
>
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<TextField
|
||||
label="Name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
data-testid="meta-edit-name"
|
||||
/>
|
||||
<TextField
|
||||
label="Client / target"
|
||||
value={client}
|
||||
onChange={(e) => setClient(e.target.value)}
|
||||
data-testid="meta-edit-client"
|
||||
/>
|
||||
<TextField
|
||||
label="Start date"
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => setDateStart(e.target.value)}
|
||||
data-testid="meta-edit-date-start"
|
||||
/>
|
||||
<TextField
|
||||
label="End date"
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => setDateEnd(e.target.value)}
|
||||
data-testid="meta-edit-date-end"
|
||||
/>
|
||||
</div>
|
||||
<MarkdownField
|
||||
label="ROE / Description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
data-testid="meta-edit-description"
|
||||
/>
|
||||
{datesInvalid && (
|
||||
<p className="font-mono text-2xs text-red" data-testid="meta-edit-date-error">
|
||||
End date must be on or after start date.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button accent="teal" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
accent="green"
|
||||
onClick={submit}
|
||||
disabled={nameInvalid || !!datesInvalid || update.isPending}
|
||||
data-testid="meta-edit-save"
|
||||
>
|
||||
{update.isPending ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Add-scenarios modal //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
interface AddScenariosModalProps {
|
||||
mission: Mission;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AddScenariosModal({ mission, open, onClose }: AddScenariosModalProps) {
|
||||
const qc = useQueryClient();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const catalogue = useScenarioCatalogue(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelected([]);
|
||||
}, [open]);
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (body: AddScenariosPayload) =>
|
||||
apiPost<Mission>(`/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<string, ScenarioTemplate>(
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={`Add scenarios to ${mission.name}`}
|
||||
accent="cyan"
|
||||
size="3xl"
|
||||
testid="mission-add-scenarios-modal"
|
||||
>
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
|
||||
{catalogue.isError && <Alert accent="red">Failed to load scenarios.</Alert>}
|
||||
{catalogue.isLoading && (
|
||||
<p className="font-mono text-xs text-text-dim">Loading…</p>
|
||||
)}
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
{selected.length} scenario{selected.length === 1 ? '' : 's'} ·{' '}
|
||||
{totalTestsToAdd} test{totalTestsToAdd === 1 ? '' : 's'} will be appended
|
||||
after the current {mission.scenarios_count}.
|
||||
</p>
|
||||
<ul
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-2"
|
||||
data-testid="add-scenarios-picker"
|
||||
>
|
||||
{catalogue.data?.items.map((sc) => {
|
||||
const isSelected = selected.includes(sc.id);
|
||||
return (
|
||||
<li key={sc.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full rounded-md border ${
|
||||
isSelected ? 'border-cyan text-cyan' : 'border-border text-text'
|
||||
} bg-bg-card p-3 text-left font-mono text-xs hover:border-cyan`}
|
||||
onClick={() => toggle(sc.id)}
|
||||
data-testid={`add-scenario-toggle-${sc.id}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-bright">{sc.name}</span>
|
||||
<Tag accent="purple">{sc.tests_count} tests</Tag>
|
||||
</div>
|
||||
{sc.description && (
|
||||
<p className="mt-1 text-text-dim">{sc.description}</p>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{catalogue.data && catalogue.data.items.length === 0 && (
|
||||
<p className="font-mono text-xs text-text-dim">
|
||||
No scenarios in the catalogue yet.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button accent="teal" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
accent="green"
|
||||
onClick={submit}
|
||||
disabled={selected.length === 0 || add.isPending}
|
||||
data-testid="add-scenarios-save"
|
||||
>
|
||||
{add.isPending ? 'Adding…' : `Add ${selected.length} scenario${selected.length === 1 ? '' : 's'}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 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<MemberSelection[]>([]);
|
||||
|
||||
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<Mission>(`/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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={`Members of ${mission.name}`}
|
||||
accent="cyan"
|
||||
size="3xl"
|
||||
testid="mission-edit-members-modal"
|
||||
>
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
|
||||
{roster.isError && <Alert accent="red">Failed to load roster.</Alert>}
|
||||
{roster.isLoading && (
|
||||
<p className="font-mono text-xs text-text-dim">Loading users…</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2" data-testid="edit-members-picker">
|
||||
{roster.data?.items.map((u) => {
|
||||
const selected = members.find((m) => m.user_id === u.id);
|
||||
return (
|
||||
<li
|
||||
key={u.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-bg-card p-3"
|
||||
data-testid={`edit-member-row-${u.id}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-mono text-xs text-text-bright">
|
||||
{u.display_name ?? u.email}
|
||||
</p>
|
||||
{u.display_name && (
|
||||
<p className="font-mono text-2xs text-text-dim">{u.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
accent="red"
|
||||
variant={selected?.role_hint === 'red' ? 'solid' : 'outline'}
|
||||
onClick={() => setRole(u.id, 'red')}
|
||||
data-testid={`edit-member-${u.id}-red`}
|
||||
>
|
||||
Red
|
||||
</Button>
|
||||
<Button
|
||||
accent="cyan"
|
||||
variant={selected?.role_hint === 'blue' ? 'solid' : 'outline'}
|
||||
onClick={() => setRole(u.id, 'blue')}
|
||||
data-testid={`edit-member-${u.id}-blue`}
|
||||
>
|
||||
Blue
|
||||
</Button>
|
||||
{selected && (
|
||||
<Button
|
||||
accent="rose"
|
||||
variant="ghost"
|
||||
onClick={() => remove(u.id)}
|
||||
data-testid={`edit-member-${u.id}-clear`}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button accent="teal" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
accent="green"
|
||||
onClick={submit}
|
||||
disabled={save.isPending}
|
||||
data-testid="edit-members-save"
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Save members'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 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<Tab>('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() {
|
||||
<SectionHeader prefix="Mission" highlight={m.name} accent={accent} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag accent={accent}>{MISSION_STATUS_LABEL[m.status]}</Tag>
|
||||
{canEdit && (
|
||||
<Button
|
||||
accent="cyan"
|
||||
variant="outline"
|
||||
onClick={() => setEditMeta(true)}
|
||||
data-testid="mission-edit-meta"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{allowedNext.map((target) => (
|
||||
<Button
|
||||
key={target}
|
||||
@@ -175,9 +626,25 @@ export function MissionDetailPage() {
|
||||
|
||||
{tab === 'tests' && (
|
||||
<Card>
|
||||
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
Snapshots are frozen at append time — editing a source template
|
||||
does not propagate.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button
|
||||
accent="cyan"
|
||||
onClick={() => setAddScenarios(true)}
|
||||
data-testid="mission-add-scenarios"
|
||||
>
|
||||
+ Add scenarios
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{m.scenarios.length === 0 ? (
|
||||
<p className="font-mono text-xs text-text-dim">
|
||||
No scenarios snapshotted yet.
|
||||
{canEdit && ' Click "Add scenarios" to append one.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4" data-testid="mission-scenarios">
|
||||
@@ -260,9 +727,25 @@ export function MissionDetailPage() {
|
||||
|
||||
{tab === 'members' && (
|
||||
<Card>
|
||||
<div className="flex items-baseline justify-between flex-wrap gap-2 mb-3">
|
||||
<p className="font-mono text-2xs text-text-dim">
|
||||
Members see this mission and (for reds) can author red-side fields
|
||||
on its tests in M7+.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button
|
||||
accent="cyan"
|
||||
onClick={() => setEditMembers(true)}
|
||||
data-testid="mission-edit-members"
|
||||
>
|
||||
Edit members
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{m.members.length === 0 ? (
|
||||
<p className="font-mono text-xs text-text-dim">
|
||||
No members assigned. An admin can add them via the API.
|
||||
No members assigned.
|
||||
{canEdit && ' Click "Edit members" to add some.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2" data-testid="mission-members">
|
||||
@@ -307,6 +790,18 @@ export function MissionDetailPage() {
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<MetaEditModal mission={m} open={editMeta} onClose={() => setEditMeta(false)} />
|
||||
<AddScenariosModal
|
||||
mission={m}
|
||||
open={addScenarios}
|
||||
onClose={() => setAddScenarios(false)}
|
||||
/>
|
||||
<EditMembersModal
|
||||
mission={m}
|
||||
open={editMembers}
|
||||
onClose={() => setEditMembers(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user