Brief edit: prefill once, never clobber typing, fall back when full is missing

After importing a brief via JSON paste, edit appeared not to work — the
textarea would either be empty or silently revert as React Query refetched
on window focus (every refetch fired the prefill useEffect, blowing away
whatever the user had typed). On top of that, if the server's `full` field
wasn't returned for any reason the textarea stayed permanently empty.

Fixes:
- Initialise the textarea exactly once via a useRef seed flag. Subsequent
  data refetches don't overwrite user-typed content.
- When `full` is missing, fall back to reconstructing the BRIEF_INPUT shape
  from public columns (client_name, brand positioning, kpis, quality floor,
  etc.) so the user has something to edit. Surfaces an amber banner noting
  competitors/audience/geo are blank and need re-entering.
- New "Reset to saved" link in the header to deliberately discard local
  changes and reload from the server.
- Disable Save when the textarea is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-29 20:35:41 -04:00
parent 378687fe5f
commit 32dbd8aa7d

View file

@ -1,12 +1,44 @@
// Brief edit page — reuses the same Zod-shape JSON the server expects on PATCH.
// Minimal UI: pre-fills a JSON textarea with the current brief's full shape and
// submits the parsed JSON. Avoids duplicating the 7-section form from `new.tsx`.
import { useEffect, useState } from 'react';
// Brief edit page. Prefills a JSON textarea from the current brief's full shape
// and submits the parsed JSON via PATCH. Robust to the brief data arriving late
// or to `full` being missing — falls back to reconstructing the brief shape
// from the public columns. Doesn't clobber user-typed content on data refetch.
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useBrief, useUpdateBrief, type BriefCreateInput } from '../../api/briefs';
import { useBrief, useUpdateBrief, type BriefCreateInput, type BriefSummary } from '../../api/briefs';
import { ApiError } from '../../api/client';
import { useMe } from '../../auth/useMe';
function reconstructFromPublic(b: BriefSummary): BriefCreateInput {
// When the server's `full` field is absent (older briefs, or any stored
// shape we can't trust), build a valid BRIEF_INPUT from the public columns.
const positioning = (b.positioning && typeof b.positioning === 'object'
? b.positioning as { positioning?: string; brand?: { name?: string; handle?: string } }
: null);
return {
client_name: b.client_name,
category: b.category,
brand: {
name: positioning?.brand?.name ?? b.client_name,
handle: positioning?.brand?.handle ?? '',
...(positioning?.positioning ? { positioning: positioning.positioning } : {}),
},
competitors: [],
audience: { primary: '', age_range: '', gender: '', interests: [] },
geo: '',
language: 'en',
business_question: b.business_question,
kpis: Array.isArray(b.kpis) ? b.kpis : [],
budget_usd: b.budget_usd,
date_window_days: b.date_window_days,
platforms: (b.platforms as 'tiktok'[]) ?? ['tiktok'],
context_vision: b.context_vision ?? undefined,
prior_report_id: b.prior_report_id ?? null,
min_likes: b.min_likes,
min_plays: b.min_plays,
min_stl_pct: b.min_stl_pct,
};
}
export default function BriefEdit() {
const { id } = useParams();
const navigate = useNavigate();
@ -16,17 +48,30 @@ export default function BriefEdit() {
const [text, setText] = useState('');
const [err, setErr] = useState<string | null>(null);
const [issues, setIssues] = useState<{ path: (string | number)[]; message: string }[] | null>(null);
const seededRef = useRef(false);
// Initialise the textarea exactly once when data first arrives. Subsequent
// refetches (window focus, invalidation) MUST NOT overwrite what the user
// has typed. If `full` is missing, fall back to the public-columns view.
useEffect(() => {
if (data?.brief?.full) setText(JSON.stringify(data.brief.full, null, 2));
if (seededRef.current || !data?.brief) return;
const source = data.brief.full ?? reconstructFromPublic(data.brief);
setText(JSON.stringify(source, null, 2));
seededRef.current = true;
}, [data]);
if (isLoading) return <div className="text-text-muted text-sm">Loading</div>;
if (isLoading) return <div className="text-text-muted text-sm">Loading brief</div>;
if (error || !data) return <div className="text-red-400 text-sm">Failed to load brief.</div>;
const role = me?.active_team?.role;
if (role !== 'editor' && role !== 'admin' && role !== 'owner') {
return <div className="text-red-400 text-sm">Editor or higher role required to edit briefs.</div>;
return <div className="text-red-400 text-sm">Editor, admin, or owner role required to edit briefs.</div>;
}
function resetFromCurrent() {
if (!data?.brief) return;
const source = data.brief.full ?? reconstructFromPublic(data.brief);
setText(JSON.stringify(source, null, 2));
}
function onSubmit(e: React.FormEvent) {
@ -37,7 +82,7 @@ export default function BriefEdit() {
catch (e2) { setErr(`Invalid JSON: ${(e2 as Error).message}`); return; }
update.mutate(parsed, {
onSuccess: () => navigate(`/briefs/${id}`),
onError: (e2) => {
onError: (e2: Error) => {
if (e2 instanceof ApiError) {
setErr(e2.message);
if (e2.issues) setIssues(e2.issues);
@ -50,16 +95,34 @@ export default function BriefEdit() {
return (
<div className="space-y-6 max-w-3xl">
<header>
<h1 className="text-2xl font-semibold">Edit brief</h1>
<p className="text-sm text-text-muted mt-1">{data.brief.client_name} · {data.brief.slug}</p>
<header className="flex items-baseline justify-between gap-3 flex-wrap">
<div>
<h1 className="text-2xl font-semibold">Edit brief</h1>
<p className="text-sm text-text-muted mt-1">{data.brief.client_name} · {data.brief.slug}</p>
</div>
<button
type="button"
onClick={resetFromCurrent}
className="text-xs text-text-muted hover:text-accent"
title="Discard local changes and reload the brief from the server"
>
Reset to saved
</button>
</header>
{!data.brief.full && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded p-3 text-xs text-amber-300">
This brief's full Zod shape wasn't returned by the server (older brief?).
The textarea below has been reconstructed from public columns
competitors / audience / geo are blank and will need re-entering.
</div>
)}
<form onSubmit={onSubmit} className="space-y-3">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={28}
className="w-full bg-bg-field border border-border-input rounded p-3 text-xs font-mono text-text-body focus:outline-none focus:border-accent"
spellCheck={false}
/>
{err && <div className="text-red-400 text-sm">{err}</div>}
{issues && (
@ -72,7 +135,7 @@ export default function BriefEdit() {
<div className="flex gap-2">
<button
type="submit"
disabled={update.isPending}
disabled={update.isPending || text.trim().length === 0}
className="bg-accent hover:bg-accent-hover text-black font-medium px-4 py-2 rounded text-sm disabled:opacity-60"
>
{update.isPending ? 'Saving…' : 'Save changes'}