Make creating a person obvious; inline "create new" when linking relatives

- Family view gets a prominent "+ Add person" button that creates a person and
  opens their page to fill in details (previously you could only add a person
  via the empty-state form or by linking from another person).
- The person page's relationship picker (PersonCombobox) now offers
  "+ Create '<typed name>'" when the person doesn't exist yet: it creates them,
  links them in the chosen role (parent/child/partner/sibling), and jumps to
  their new page to edit — no more create-then-go-back-and-link.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:30:14 -04:00
parent e9b2436ce0
commit 37ac49767e
3 changed files with 65 additions and 16 deletions
+17 -6
View File
@@ -126,6 +126,12 @@ export default function FamilyViewPage() {
return data?.id ?? null; return data?.id ?? null;
} }
// Create a new (blank) person and open their page to fill in details.
async function newPersonAndGo() {
const id = await addPerson("");
if (id) router.push(`/trees/${treeId}/persons/${id}`);
}
async function createFirst(e: React.FormEvent) { async function createFirst(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!firstName.trim()) return; if (!firstName.trim()) return;
@@ -352,12 +358,17 @@ export default function FamilyViewPage() {
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Family view</h1> <h1 className="text-2xl font-semibold">Family view</h1>
<Link <div className="flex items-center gap-3">
href={`/trees/${treeId}/persons/${focus.id}`} <Button size="sm" onClick={newPersonAndGo}>
className="text-sm text-bronze hover:underline" + Add person
> </Button>
Open {focus.primary_name ?? "person"} <Link
</Link> href={`/trees/${treeId}/persons/${focus.id}`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
</Link>
</div>
</div> </div>
{/* Pedigree: focus → parents → grandparents, with bracket connectors */} {/* Pedigree: focus → parents → grandparents, with bracket connectors */}
@@ -342,28 +342,47 @@ export default function PersonDetailPage() {
} }
} }
async function addRel(e: React.FormEvent) { async function linkRelative(otherId: string): Promise<boolean> {
e.preventDefault();
if (!relOther) return;
let body: RelCreate; let body: RelCreate;
if (relKind === "parent") { if (relKind === "parent") {
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual }; body = { type: "parent_child", person_from_id: otherId, person_to_id: personId, qualifier: relQual };
} else if (relKind === "child") { } else if (relKind === "child") {
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual }; body = { type: "parent_child", person_from_id: personId, person_to_id: otherId, qualifier: relQual };
} else if (relKind === "partner") { } else if (relKind === "partner") {
body = { type: "partnership", person_from_id: personId, person_to_id: relOther }; body = { type: "partnership", person_from_id: personId, person_to_id: otherId };
} else { } else {
body = { type: "sibling", person_from_id: personId, person_to_id: relOther }; body = { type: "sibling", person_from_id: personId, person_to_id: otherId };
} }
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", { const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body, body,
}); });
if (!error) { return !error;
}
async function addRel(e: React.FormEvent) {
e.preventDefault();
if (!relOther) return;
if (await linkRelative(relOther)) {
setRelOther(""); setRelOther("");
load(); load();
} }
} }
// Create a brand-new person, link them in the chosen role, then jump to their
// page so the user can fill in details immediately.
async function createRelativeAndGo(name: string) {
const toks = name.trim().split(/\s+/).filter(Boolean);
const given = toks.length > 1 ? toks.slice(0, -1).join(" ") : toks[0] ?? name.trim();
const surname = toks.length > 1 ? toks[toks.length - 1] : null;
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
body: { given, surname },
});
if (!data) return;
await linkRelative(data.id);
router.push(`/trees/${treeId}/persons/${data.id}`);
}
async function removeRel(id: string) { async function removeRel(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", { await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
params: { path: { tree_id: treeId, relationship_id: id } }, params: { path: { tree_id: treeId, relationship_id: id } },
@@ -1096,7 +1115,8 @@ export default function PersonDetailPage() {
people={others} people={others}
value={relOther} value={relOther}
onChange={setRelOther} onChange={setRelOther}
placeholder="Search for a person…" onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/> />
{(relKind === "parent" || relKind === "child") && ( {(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}> <select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
+19 -1
View File
@@ -16,12 +16,15 @@ export function PersonCombobox({
people, people,
value, value,
onChange, onChange,
onCreate,
placeholder = "Search for a person…", placeholder = "Search for a person…",
className, className,
}: { }: {
people: Person[]; people: Person[];
value: string; value: string;
onChange: (id: string) => void; onChange: (id: string) => void;
/** When set, the dropdown offers a "Create '<typed name>'" action. */
onCreate?: (name: string) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
}) { }) {
@@ -77,7 +80,7 @@ export function PersonCombobox({
if (value) onChange(""); // typing invalidates the prior pick if (value) onChange(""); // typing invalidates the prior pick
}} }}
/> />
{open && matches.length > 0 && ( {open && (matches.length > 0 || (onCreate && query.trim())) && (
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg"> <ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => ( {matches.map((p) => (
<li key={p.id}> <li key={p.id}>
@@ -96,6 +99,21 @@ export function PersonCombobox({
</button> </button>
</li> </li>
))} ))}
{onCreate && query.trim() && (
<li className={matches.length > 0 ? "border-t border-[var(--border)]" : ""}>
<button
type="button"
onClick={() => {
const name = query.trim();
setOpen(false);
onCreate(name);
}}
className="block w-full px-3 py-2 text-left text-sm text-bronze hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
>
+ Create {query.trim()}
</button>
</li>
)}
</ul> </ul>
)} )}
</div> </div>