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:
parent
378687fe5f
commit
32dbd8aa7d
1 changed files with 76 additions and 13 deletions
|
|
@ -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'}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue