From 32dbd8aa7d36a6c255cf7d3161a536247697cd4f Mon Sep 17 00:00:00 2001 From: DJP Date: Wed, 29 Apr 2026 20:35:41 -0400 Subject: [PATCH] Brief edit: prefill once, never clobber typing, fall back when `full` is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- v2/operator-app/src/routes/briefs/edit.tsx | 89 ++++++++++++++++++---- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/v2/operator-app/src/routes/briefs/edit.tsx b/v2/operator-app/src/routes/briefs/edit.tsx index 7cdc44b..56cab57 100644 --- a/v2/operator-app/src/routes/briefs/edit.tsx +++ b/v2/operator-app/src/routes/briefs/edit.tsx @@ -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(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
Loading…
; + if (isLoading) return
Loading brief…
; if (error || !data) return
Failed to load brief.
; const role = me?.active_team?.role; if (role !== 'editor' && role !== 'admin' && role !== 'owner') { - return
Editor or higher role required to edit briefs.
; + return
Editor, admin, or owner role required to edit briefs.
; + } + + 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 (
-
-

Edit brief

-

{data.brief.client_name} · {data.brief.slug}

+
+
+

Edit brief

+

{data.brief.client_name} · {data.brief.slug}

+
+
+ {!data.brief.full && ( +
+ 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. +
+ )}