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