Link Groups to Contacts
Link Groups to Contacts
The last piece: letting a contact actually belong to groups, from the
create/edit form, and showing those groups as chips on the detail page.
Fetch the groups for one contact
Open lib/contacts.ts and add:
import type { Group } from "./types";
export async function getGroupsForContact(contactId: string): Promise<Group[]> {
const result = await pool.query(
`SELECT g.* FROM groups g
JOIN contact_groups cg ON cg.group_id = g.id
WHERE cg.contact_id = $1
ORDER BY lower(g.name) ASC`,
[contactId]
);
return result.rows;
}
Update createContact and updateContact to accept group ids
This is the trickiest part of the module: saving a contact and updating
its group memberships needs to happen as a single unit. If the contact
insert succeeded but the group-linking failed halfway through, you’d end
up with a contact that’s silently missing some of its groups. A Postgres
transaction (BEGIN / COMMIT / ROLLBACK) prevents that — either
everything in it succeeds, or none of it does.
Replace createContact and updateContact in lib/contacts.ts:
export async function createContact(input: ContactInput): Promise<Contact> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await client.query(
`INSERT INTO contacts (name, phone, email, company, notes, favorite)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[
input.name,
input.phone || null,
input.email || null,
input.company || null,
input.notes || null,
!!input.favorite,
]
);
const contact = result.rows[0];
if (input.groupIds && input.groupIds.length) {
await setContactGroups(client, contact.id, input.groupIds);
}
await client.query("COMMIT");
return contact;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
export async function updateContact(id: string, input: ContactInput): Promise<Contact> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await client.query(
`UPDATE contacts SET
name = $1, phone = $2, email = $3, company = $4, notes = $5,
favorite = $6, updated_at = now()
WHERE id = $7 RETURNING *`,
[
input.name,
input.phone || null,
input.email || null,
input.company || null,
input.notes || null,
!!input.favorite,
id,
]
);
await setContactGroups(client, id, input.groupIds || []);
await client.query("COMMIT");
return result.rows[0];
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async function setContactGroups(client: any, contactId: string, groupIds: string[]) {
await client.query(`DELETE FROM contact_groups WHERE contact_id = $1`, [contactId]);
for (const groupId of groupIds) {
await client.query(
`INSERT INTO contact_groups (contact_id, group_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[contactId, groupId]
);
}
}
Two changes from the version you wrote in Module 5:
pool.query(...)becameclient.query(...), using a single checked-out
clientfor the whole transaction —BEGIN, the insert/update, the
group sync, andCOMMITall need to run on the same database
connection to be part of the same transaction.setContactGroupstakes the simplest possible approach to syncing:
delete every existing membership for this contact, then re-insert
exactly the groups that were checked in the form. It’s a few more
queries than a more surgical diff-and-patch approach, but it’s simple
and correct — with a handful of groups per contact, the extra queries
cost nothing noticeable.
Also add groupIds to ContactInput in lib/types.ts:
export type ContactInput = {
name: string;
phone?: string;
email?: string;
company?: string;
notes?: string;
favorite?: boolean;
groupIds?: string[];
};
Read group checkboxes in the Server Actions
Open lib/actions.ts and update readContactInput:
function readContactInput(formData: FormData) {
const groupIds = formData.getAll("groups").map((g) => String(g));
return {
name: String(formData.get("name") || "").trim(),
phone: String(formData.get("phone") || "").trim(),
email: String(formData.get("email") || "").trim(),
company: String(formData.get("company") || "").trim(),
notes: String(formData.get("notes") || "").trim(),
favorite: formData.get("favorite") === "on",
groupIds,
};
}
formData.getAll(...) (not .get(...)) is what you need for a group of
checkboxes that share the same name attribute — it returns every checked
value as an array, instead of just the first one.
Add group checkboxes to the form
Open components/ContactForm.tsx. Add allGroups and selectedGroupIds
props, and render a checkbox per group:
import type { Contact, Group } from "@/lib/types";
type Props = {
action: (formData: FormData) => void;
contact?: Contact;
allGroups: Group[];
selectedGroupIds?: string[];
submitLabel: string;
};
export default function ContactForm({
action,
contact,
allGroups,
selectedGroupIds = [],
submitLabel,
}: Props) {
return (
<form action={action} className="form-card">
{/* ...existing name/phone/email/company/notes fields... */}
{allGroups.length > 0 ? (
<div className="field groups-field">
<label>Groups</label>
<div className="chips">
{allGroups.map((g) => (
<label key={g.id} className="chip-check">
<input
type="checkbox"
name="groups"
value={g.id}
defaultChecked={selectedGroupIds.includes(g.id)}
/>
{g.name}
</label>
))}
</div>
</div>
) : null}
{/* ...existing favorite toggle and form-actions... */}
</form>
);
}
Every checkbox shares name="groups" — that’s exactly what
formData.getAll("groups") on the server is built to read.
Pass groups into the New and Edit pages
Open app/contacts/new/page.tsx:
import { getGroups } from "@/lib/groups";
export default async function NewContactPage() {
const allGroups = await getGroups();
return (
<>
<div className="page-heading"><h1>New contact</h1></div>
<ContactForm action={createContactAction} allGroups={allGroups} submitLabel="Save contact" />
</>
);
}
Open app/contacts/[id]/edit/page.tsx:
import { getGroups } from "@/lib/groups";
import { getGroupsForContact } from "@/lib/contacts";
export default async function EditContactPage({ params }: { params: { id: string } }) {
const contact = await getContactById(params.id);
if (!contact) notFound();
const [allGroups, contactGroups] = await Promise.all([
getGroups(),
getGroupsForContact(params.id),
]);
const boundAction = updateContactAction.bind(null, params.id);
return (
<>
<div className="page-heading"><h1>Edit {contact.name}</h1></div>
<ContactForm
action={boundAction}
contact={contact}
allGroups={allGroups}
selectedGroupIds={contactGroups.map((g) => g.id)}
submitLabel="Save changes"
/>
</>
);
}
Promise.all runs both queries concurrently rather than one after the
other — a small but easy win any time you have two independent database
calls that don’t depend on each other’s results.
Show group chips on the detail page
Open app/contacts/[id]/page.tsx:
import { getGroupsForContact } from "@/lib/contacts";
// inside the component, after fetching `contact`:
const groups = await getGroupsForContact(params.id);
// in the JSX, alongside the other detail-row blocks:
{groups.length > 0 ? (
<div className="detail-row">
<div className="detail-label">Groups</div>
<div className="detail-value chips">
{groups.map((g) => (
<Link key={g.id} href={`/groups/${g.id}`} className="chip">{g.name}</Link>
))}
</div>
</div>
) : null}
Test it
Edit a contact and check the “Work” group’s checkbox, then save. On the
detail page, you should see a “Work” chip — click it and you should land
on the Work group’s page, now showing that contact. Edit the contact again
and uncheck the group; save; the chip should disappear and the group
detail page should go back to empty. Create a new contact and check two
groups at once — both should appear as chips immediately.
Checkpoint
A contact can belong to any number of groups, set from the same form used
to create and edit it. Group membership is reflected immediately on the
contact’s detail page and on each group’s own page, and survives edits in
either direction (adding and removing).