CodingNic

Groups

Link Groups to Contacts

Groups 30 min read

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:

typescript
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:

typescript
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(...) became client.query(...), using a single checked-out
    client for the whole transaction — BEGIN, the insert/update, the
    group sync, and COMMIT all need to run on the same database
    connection to be part of the same transaction.
  • setContactGroups takes 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:

typescript
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:

typescript
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:

tsx
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:

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:

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:

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).