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:
Knacky
2026-05-14 07:37:06 +02:00
parent 4d2b6731ac
commit b62651a215
3 changed files with 590 additions and 3 deletions

View File

@@ -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>
);
}