From 3ddf153e303c4639ba3836a820e9d4347bec74da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:39:44 +0000 Subject: [PATCH] fix(seating): searching a family or subgroup by name finds its members The guest panel's search compared each guest with their own names and the top-level group's name only. A family sits inside a subgroup inside a group in any real plan, so searching "The Engines" found nobody unless one of them happened to share the name. Search now matches the names of the group, subgroup and family each guest is in, the same way at every level of the list. Closes #57 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VhFp1PtVBwgmxST9ZrRTEs --- .../components/guestPanel/GuestPanel.test.tsx | 62 +++++++++++++++++++ .../components/guestPanel/GuestPanel.tsx | 16 ++--- suite/apps/tableaux/utils/guestFilters.ts | 16 ++--- 3 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 suite/apps/tableaux/components/guestPanel/GuestPanel.test.tsx diff --git a/suite/apps/tableaux/components/guestPanel/GuestPanel.test.tsx b/suite/apps/tableaux/components/guestPanel/GuestPanel.test.tsx new file mode 100644 index 00000000..fb20c610 --- /dev/null +++ b/suite/apps/tableaux/components/guestPanel/GuestPanel.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import GuestPanel from './GuestPanel' +import { useStore } from '../../store/useStore' +import { openPlan } from '../../test/openPlan' +import { newGuest } from '@/lib/model/factories' +import type { Guest } from '../../store/types' + +const guest = (id: string, fullName: string, where: Partial): Guest => + ({ ...newGuest({ id, rsvpStatus: 'confirmed' }), fullName, assignedSeatId: null, ...where }) as Guest + +// Families sit inside a subgroup inside a group, which is how a real plan is +// built. Searching any container's name should find the people in it. +beforeEach(() => { + openPlan({ + guests: { + g1: guest('g1', 'Ada Lovelace', { groupId: 'grp', subgroupId: 'sub', familyId: 'fam' }), + g2: guest('g2', 'Byron Lovelace', { groupId: 'grp', subgroupId: 'sub', familyId: 'fam' }), + g3: guest('g3', 'Mary Somerville', { groupId: 'grp', subgroupId: 'sub' }), + g4: guest('g4', 'Alan Turing', { groupId: 'grp' }), + }, + groups: { grp: { id: 'grp', name: 'Work', colour: '#000', memberIds: ['g1', 'g2', 'g3', 'g4'] } }, + subgroups: { sub: { id: 'sub', name: 'Analysts', colour: '#000', parentGroupId: 'grp', memberIds: ['g1', 'g2', 'g3'] } }, + families: { + fam: { id: 'fam', name: 'The Engines', colour: '#000', parentGroupId: null, parentSubgroupId: 'sub', memberIds: ['g1', 'g2'] }, + }, + }) +}) + +const search = (query: string) => { + useStore.setState({ search: query }) + render() +} + +describe('GuestPanel search', () => { + it("finds a family's members by the family's name", () => { + search('engines') + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('Byron Lovelace')).toBeInTheDocument() + expect(screen.queryByText('Mary Somerville')).toBeNull() + expect(screen.queryByText('Alan Turing')).toBeNull() + }) + + it("finds a subgroup's members by the subgroup's name", () => { + search('analysts') + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + expect(screen.getByText('Mary Somerville')).toBeInTheDocument() + expect(screen.queryByText('Alan Turing')).toBeNull() + }) + + it("still finds a group's members by the group's name", () => { + search('work') + expect(screen.getByText('Alan Turing')).toBeInTheDocument() + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + }) + + it('still finds a person by their own name', () => { + search('turing') + expect(screen.getByText('Alan Turing')).toBeInTheDocument() + expect(screen.queryByText('Ada Lovelace')).toBeNull() + }) +}) diff --git a/suite/apps/tableaux/components/guestPanel/GuestPanel.tsx b/suite/apps/tableaux/components/guestPanel/GuestPanel.tsx index b4a96946..c2adaae7 100644 --- a/suite/apps/tableaux/components/guestPanel/GuestPanel.tsx +++ b/suite/apps/tableaux/components/guestPanel/GuestPanel.tsx @@ -12,6 +12,7 @@ import { useGuestImport } from '@/components/shell/guestImportPanel' import { matchesSearch, matchesFilters } from '../../utils/guestFilters' import { isComing } from '@/lib/model/slices' import type { ShownFamily, ShownGroup, ShownSubgroup } from './tree' +import type { Guest } from '../../store/types' import styles from './GuestPanel.module.css' /** @@ -80,18 +81,17 @@ export default function GuestPanel() { const subgroupArr = Object.values(subgroups) const familyArr = Object.values(families) const showEmpty = !search && !filters.length + const shown = (g: Guest) => { + const names = [groups[g.groupId ?? '']?.name, subgroups[g.subgroupId ?? '']?.name, families[g.familyId ?? '']?.name] + return matchesSearch(g, names, search) && matchesFilters(g, filters) + } const vGroups = groupArr .map((group): ShownGroup => { - // TODO(family-ux): matchesSearch is only ever given the top-level - // `group`, so searching a subgroup or family NAME doesn't surface its - // members (only a personal-name match does) — a real gap for nested - // families, which is how every family in the live plan is set up. - // See tmp/family-ux-followups.md #2. const allMembers = (group.memberIds || []) .map((id) => guests[id]) .filter(Boolean) - .filter((g) => matchesSearch(g, group, search) && matchesFilters(g, filters)) + .filter(shown) // Build subgroup sections: each subgroup with its matching members, // and any families nested inside that subgroup. @@ -155,7 +155,7 @@ export default function GuestPanel() { members: (f.memberIds || []) .map((id) => guests[id]) .filter(Boolean) - .filter((g) => matchesSearch(g, f, search) && matchesFilters(g, filters)), + .filter(shown), })) .filter((vf) => vf.members.length > 0 || showEmpty) .sort((a, b) => String(a.family.name).localeCompare(String(b.family.name))) @@ -163,7 +163,7 @@ export default function GuestPanel() { const ung = list .filter((g) => !g.groupId || !groups[g.groupId]) .filter((g) => !g.familyId || !families[g.familyId]) - .filter((g) => matchesSearch(g, null, search) && matchesFilters(g, filters)) + .filter(shown) .sort((a, b) => String(a.fullName).localeCompare(String(b.fullName))) return { diff --git a/suite/apps/tableaux/utils/guestFilters.ts b/suite/apps/tableaux/utils/guestFilters.ts index fc67d4dd..e2bed87e 100644 --- a/suite/apps/tableaux/utils/guestFilters.ts +++ b/suite/apps/tableaux/utils/guestFilters.ts @@ -1,6 +1,6 @@ import { sideShort } from '@/lib/model/partners' import { isComing } from '@/lib/model/slices' -import type { Group, Guest, Meta } from '../store/types' +import type { Guest, Meta } from '../store/types' // TODO(family-ux): no "in a family" (or per-family) filter chip exists — // would need an entry here AND in PREDICATES below, and a predicate can't @@ -48,15 +48,17 @@ export function matchesFilters(guest: Guest, filters: readonly string[] | null | return filters.every((f) => (f in PREDICATES ? PREDICATES[f as FilterKey](guest) : true)) } -export function matchesSearch(guest: Guest, group: Pick | null | undefined, query: string): boolean { +/** + * Does the guest match the search box? Their own names count, and so do the + * names of the group, subgroup and family they are in, so searching "The + * Engines" finds the Engines. + */ +export function matchesSearch(guest: Guest, containerNames: readonly (string | undefined)[], query: string): boolean { if (!query) return true const q = query.trim().toLowerCase() if (!q) return true - return ( - (guest.fullName || '').toLowerCase().includes(q) || - (guest.firstName || '').toLowerCase().includes(q) || - (guest.lastName || '').toLowerCase().includes(q) || - (group?.name || '').toLowerCase().includes(q) + return [guest.fullName, guest.firstName, guest.lastName, ...containerNames].some((name) => + (name || '').toLowerCase().includes(q) ) }