From b7ae6012b32366ff4886ff08e4eeb06e2265c121 Mon Sep 17 00:00:00 2001 From: Bjjj834 Date: Thu, 30 Jul 2026 16:54:28 -0400 Subject: [PATCH 1/2] test: wire up @testing-library/jest-dom via setupTests.js jest-dom was already a devDependency but no setupTests.js imported it, so matchers like toBeInTheDocument/toHaveValue were unavailable to any test in the suite. --- frontend/src/setupTests.js | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 frontend/src/setupTests.js diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 0000000..9cff928 --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1,3 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// https://github.com/testing-library/jest-dom +import "@testing-library/jest-dom"; From a5e3fa3a55c0f5579522f4bdf25129ae0b4672ee Mon Sep 17 00:00:00 2001 From: Bjjj834 Date: Thu, 30 Jul 2026 16:55:37 -0400 Subject: [PATCH 2/2] feat: replace Members inline filters with a Filter drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the Members page filtering UX per feedback: the page now shows only a search bar and a single "Filter" button (with an active-filter count badge), instead of separate Company/Job Title dropdowns inline. Clicking Filter opens a right-side drawer (near-full-width on mobile) with two collapsible, searchable, multi-select checkbox sections. Since the full ~993-member dataset is already loaded client-side (see prior investigation), all of this — filtering, pagination, and facet counts — operates on the complete dataset, not just the visible page. No backend changes were needed or made. - jobTitleNormalization.js: explicit, documented canonical-mapping utility that merges job titles differing only in separator/formatting (e.g. "Founder & CEO" / "Founder + CEO" / "Founder and CEO" / "Founder CEO" -> "Founder / CEO"), while never merging distinct roles or seniority levels (e.g. "Product Manager" vs "Senior Product Manager", "CEO" vs "Founder / CEO"). A member's original stored title is never overwritten — normalization is used only for grouping/filtering. - memberFacets.js: filtering (AND across Company/Job Title sections, OR within a section, combined with general search) plus true faceted counts — Company counts respect the current search + selected Job Titles, Job Title counts respect search + selected Companies. A selected option stays visible (count 0) even if another filter zeroes it out. Blank/null/placeholder values ("N/A", "-", etc.) are excluded from filter options entirely. - FilterSection.js / FilterDrawer.js: collapsible search-within-section checkbox lists, backdrop + Escape-to-close + manual focus trap (useFocusTrap.js), "Clear all" and "Done" actions. - ArmorMembers.js: wires it all together — active-filter chips in the main area, empty state, search/company/jobTitle/page synced to URL query params (comma-separated multi-values, empty params omitted), pagination resets to page 1 on filter change but not on initial URL-hydrated load, and debounces the free-text search box. Verified manually against the live ~993-row dataset in a browser (desktop + mobile viewports): faceted counts, cross-filtering, URL refresh/share, and the "N/A"/"NA" placeholder exclusion all behave correctly. --- frontend/src/__tests__/ArmorMembers.test.js | 301 ++++++++++++ .../__tests__/jobTitleNormalization.test.js | 94 ++++ frontend/src/__tests__/memberFacets.test.js | 131 ++++++ .../landing_page_armor/ArmorMembers.js | 436 +++++++++++++----- .../landing_page_armor/FilterDrawer.js | 98 ++++ .../landing_page_armor/FilterSection.js | 102 ++++ .../jobTitleNormalization.js | 86 ++++ .../landing_page_armor/memberFacets.js | 158 +++++++ frontend/src/util/useDebouncedValue.js | 14 + frontend/src/util/useFocusTrap.js | 50 ++ 10 files changed, 1356 insertions(+), 114 deletions(-) create mode 100644 frontend/src/__tests__/ArmorMembers.test.js create mode 100644 frontend/src/__tests__/jobTitleNormalization.test.js create mode 100644 frontend/src/__tests__/memberFacets.test.js create mode 100644 frontend/src/landing_page/landing_page_armor/FilterDrawer.js create mode 100644 frontend/src/landing_page/landing_page_armor/FilterSection.js create mode 100644 frontend/src/landing_page/landing_page_armor/jobTitleNormalization.js create mode 100644 frontend/src/landing_page/landing_page_armor/memberFacets.js create mode 100644 frontend/src/util/useDebouncedValue.js create mode 100644 frontend/src/util/useFocusTrap.js diff --git a/frontend/src/__tests__/ArmorMembers.test.js b/frontend/src/__tests__/ArmorMembers.test.js new file mode 100644 index 0000000..4de1dbd --- /dev/null +++ b/frontend/src/__tests__/ArmorMembers.test.js @@ -0,0 +1,301 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import ArmorMembers from "../landing_page/landing_page_armor/ArmorMembers"; + +const HEADER = ["First Name", "Last Name", "Company Name", "Job Title", "LinkedIn"]; + +// 30 members so the unfiltered dataset spans 3 pages at pageSize=10. The +// non-"Other Co" members are deliberately scattered across page 1 (idx 0/7), +// page 2 (idx 15), and page 3 (idx 20/29) so full-dataset-filtering bugs +// (only filtering the visible page) are caught. +function buildRows() { + const special = { + 0: ["Anote", "Founder / CEO"], + 7: ["Google", "Product Manager"], + 15: ["Anote", "Data Scientist"], + 20: ["Google", "Senior Product Manager"], + 29: ["Anote", "Founder & CEO"], // equivalent title group to idx 0 + }; + const rows = []; + for (let i = 0; i < 30; i++) { + const [company, title] = special[i] || ["Other Co", "Engineer"]; + rows.push([`Member${i}`, `Test${i}`, company, title, ""]); + } + return rows; +} + +const SHEET_VALUES = [HEADER, ...buildRows()]; + +function LocationProbe() { + const location = useLocation(); + return
{location.search}
; +} + +function renderMembers(initialEntries = ["/community/members"]) { + return render( + + + + + ); +} + +async function waitForLoaded() { + await screen.findByRole("heading", { name: "Community Members" }); +} + +function openDrawer() { + fireEvent.click(screen.getByRole("button", { name: /Open filters/ })); +} + +function getCompanyCheckbox(label) { + return screen.getByRole("checkbox", { name: label }); +} + +beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ values: SHEET_VALUES }), + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test("shows a loading state while the sheet is being fetched", () => { + global.fetch.mockReturnValue(new Promise(() => {})); + renderMembers(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); +}); + +test("shows an error state when the fetch fails", async () => { + global.fetch.mockRejectedValue(new Error("Network down")); + renderMembers(); + await screen.findByText(/Error: Network down/i); +}); + +test("opens the filter drawer on click and closes on Escape and via the close button", async () => { + renderMembers(); + await waitForLoaded(); + + expect(screen.queryByRole("dialog", { name: "Filter members" })).not.toBeInTheDocument(); + + openDrawer(); + expect(screen.getByRole("dialog", { name: "Filter members" })).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + openDrawer(); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); +}); + +test("shows Company options with full-dataset counts, not just the current page", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + expect(getCompanyCheckbox("Anote")).toBeInTheDocument(); + expect(screen.getByLabelText("3 members")).toBeInTheDocument(); + expect(getCompanyCheckbox("Google")).toBeInTheDocument(); + expect(getCompanyCheckbox("Other Co")).toBeInTheDocument(); +}); + +test("shows normalized Job Title options with merged counts, without merging distinct seniority levels", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + // "Founder / CEO" merges idx0 ("Founder / CEO") + idx29 ("Founder & CEO") = 2 + expect(getCompanyCheckbox("Founder / CEO")).toBeInTheDocument(); + expect(getCompanyCheckbox("Product Manager")).toBeInTheDocument(); + expect(getCompanyCheckbox("Senior Product Manager")).toBeInTheDocument(); + // both distinct options must exist independently (no accidental merge) + expect(getCompanyCheckbox("Product Manager")).not.toBe(getCompanyCheckbox("Senior Product Manager")); +}); + +test("searches within the Company section", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + fireEvent.change(screen.getByPlaceholderText("Search companies…"), { target: { value: "goog" } }); + expect(getCompanyCheckbox("Google")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Anote" })).not.toBeInTheDocument(); +}); + +test("searches within the Job Title section", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + fireEvent.change(screen.getByPlaceholderText("Search job titles…"), { target: { value: "senior" } }); + expect(getCompanyCheckbox("Senior Product Manager")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: "Product Manager" })).not.toBeInTheDocument(); +}); + +test("selecting multiple companies uses OR logic and updates the Filter button count", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + fireEvent.click(getCompanyCheckbox("Anote")); + fireEvent.click(getCompanyCheckbox("Google")); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + + await screen.findByText("Showing 5 members"); // Anote(3) + Google(2) + expect(screen.getByRole("button", { name: /2 active/ })).toHaveTextContent("Filter (2)"); +}); + +test("selecting multiple job titles uses OR logic within the section", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + fireEvent.click(getCompanyCheckbox("Product Manager")); + fireEvent.click(getCompanyCheckbox("Senior Product Manager")); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + + await screen.findByText("Showing 2 members"); +}); + +test("AND logic between Company and Job Title sections", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + fireEvent.click(getCompanyCheckbox("Google")); + fireEvent.click(getCompanyCheckbox("Product Manager")); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + + // Only idx7 (Google + Product Manager); idx20 is Google + Senior Product Manager. + await screen.findByText("Showing 1 member"); + expect(screen.getByText("Member7")).toBeInTheDocument(); +}); + +test("combines Company + Job Title filters with the general search box", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + fireEvent.click(getCompanyCheckbox("Anote")); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + fireEvent.change(screen.getByLabelText("Search members"), { target: { value: "Member29" } }); + + await screen.findByText("Showing 1 member", {}, { timeout: 2000 }); + expect(screen.getByText("Member29")).toBeInTheDocument(); +}); + +test("resets pagination to page 1 when a filter changes", async () => { + renderMembers(["/community/members?page=2"]); + await waitForLoaded(); + expect(screen.getByLabelText("Current page")).toHaveValue(2); + + openDrawer(); + fireEvent.click(getCompanyCheckbox("Other Co")); + + await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(1)); +}); + +test("preserves active filters while navigating between pages", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + fireEvent.click(getCompanyCheckbox("Other Co")); // 25 matches -> 3 pages + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + + await screen.findByText("Showing 25 members"); + fireEvent.click(screen.getByLabelText("Next page")); + + await waitFor(() => expect(screen.getByLabelText("Current page")).toHaveValue(2)); + expect(screen.getByRole("button", { name: "Remove Company filter: Other Co" })).toBeInTheDocument(); +}); + +test("clears all filters via the drawer's Clear all action", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + fireEvent.click(getCompanyCheckbox("Anote")); + fireEvent.click(getCompanyCheckbox("Founder / CEO")); + await screen.findByText("Showing 2 members"); // idx0 + idx29 (equivalent title group) + + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + + await screen.findByText("Showing 30 members"); + expect(screen.queryByRole("button", { name: /Remove Company filter/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open filters" })).toHaveTextContent("Filter"); +}); + +test("shows an empty state with a working clear action when nothing matches", async () => { + // Hydrated directly via URL: Company and Job Title cross-filter each other, + // so a real user can't sequentially check two mutually-exclusive options in + // one drawer session (selecting either one hides the other's now-zero-count + // option). Loading both at once via URL exercises the same "zero matches, + // but selections stay visible" path a shared link would hit. + renderMembers(["/community/members?company=Google&jobTitle=Data%20Scientist"]); + await waitForLoaded(); + + await screen.findByText("No members match the selected filters."); + expect(screen.getByRole("button", { name: "Remove Company filter: Google" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove Job Title filter: Data Scientist" })).toBeInTheDocument(); + + openDrawer(); + expect(getCompanyCheckbox("Google")).toBeChecked(); + expect(getCompanyCheckbox("Data Scientist")).toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + await screen.findByText("Showing 30 members"); +}); + +test("removing an individual chip clears only that filter", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + fireEvent.click(getCompanyCheckbox("Anote")); + fireEvent.click(getCompanyCheckbox("Founder / CEO")); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + await screen.findByText("Showing 2 members"); // idx0 + idx29 (equivalent title group) + + fireEvent.click(screen.getByRole("button", { name: "Remove Job Title filter: Founder / CEO" })); + + await screen.findByText("Showing 3 members"); // back to just Company: Anote + expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument(); +}); + +test("hydrates filters and page from the URL, and syncs the URL on change", async () => { + renderMembers(["/community/members?company=Anote&jobTitle=Founder%20%2F%20CEO"]); + await waitForLoaded(); + + await screen.findByText("Showing 2 members"); // idx0 + idx29 + expect(screen.getByRole("button", { name: /2 active/ })).toHaveTextContent("Filter (2)"); + expect(screen.getByRole("button", { name: "Remove Company filter: Anote" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove Job Title filter: Founder / CEO" })).toBeInTheDocument(); + + const search = screen.getByTestId("location-search").textContent; + expect(search).toContain("company=Anote"); + expect(search).toContain("jobTitle=Founder"); + + openDrawer(); + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + await waitFor(() => expect(screen.getByTestId("location-search").textContent).toBe("")); +}); + +test("filters apply to the full dataset, not only the currently visible page", async () => { + renderMembers(); + await waitForLoaded(); + openDrawer(); + + fireEvent.click(getCompanyCheckbox("Anote")); + fireEvent.click(screen.getByRole("button", { name: "Close filters" })); + + await screen.findByText("Showing 3 members"); + // idx0 (page 1), idx15 (page 2), idx29 (page 3) would be unreachable + // together under naive current-page-only filtering. + expect(screen.getByText("Member0")).toBeInTheDocument(); + expect(screen.getByText("Member15")).toBeInTheDocument(); + expect(screen.getByText("Member29")).toBeInTheDocument(); +}); diff --git a/frontend/src/__tests__/jobTitleNormalization.test.js b/frontend/src/__tests__/jobTitleNormalization.test.js new file mode 100644 index 0000000..3c26e3a --- /dev/null +++ b/frontend/src/__tests__/jobTitleNormalization.test.js @@ -0,0 +1,94 @@ +import { + cleanTitle, + normalizeSeparators, + jobTitleGroupKey, + canonicalJobTitleDisplay, +} from "../landing_page/landing_page_armor/jobTitleNormalization"; + +describe("cleanTitle", () => { + test("trims and collapses whitespace only", () => { + expect(cleanTitle(" Founder / CEO ")).toBe("Founder / CEO"); + expect(cleanTitle(null)).toBe(""); + expect(cleanTitle(undefined)).toBe(""); + }); +}); + +describe("normalizeSeparators", () => { + test("collapses &, +, comma, and 'and' into a single canonical separator", () => { + expect(normalizeSeparators("Founder & CEO")).toBe("Founder / CEO"); + expect(normalizeSeparators("Founder + CEO")).toBe("Founder / CEO"); + expect(normalizeSeparators("Founder and CEO")).toBe("Founder / CEO"); + expect(normalizeSeparators("Founder, CEO")).toBe("Founder / CEO"); + expect(normalizeSeparators("Founder / CEO")).toBe("Founder / CEO"); + }); + + test("does not touch titles with no separators", () => { + expect(normalizeSeparators("Product Manager")).toBe("Product Manager"); + expect(normalizeSeparators("Data Scientist")).toBe("Data Scientist"); + }); + + test("does not merge different words just because they're related", () => { + // These should remain distinguishable strings — normalizeSeparators only + // rewrites separators, it never removes or substitutes words. + expect(normalizeSeparators("Senior Product Manager")).toBe("Senior Product Manager"); + expect(normalizeSeparators("Product Marketing Manager")).toBe("Product Marketing Manager"); + expect(normalizeSeparators("Engineering Manager")).toBe("Engineering Manager"); + }); +}); + +describe("jobTitleGroupKey", () => { + test("groups equivalent CEO-title variants under the same key", () => { + const key = jobTitleGroupKey("Founder / CEO"); + expect(jobTitleGroupKey("Founder & CEO")).toBe(key); + expect(jobTitleGroupKey("Founder + CEO")).toBe(key); + expect(jobTitleGroupKey("founder and ceo")).toBe(key); + expect(jobTitleGroupKey(" FOUNDER and CEO ")).toBe(key); + }); + + test("is case-insensitive", () => { + expect(jobTitleGroupKey("Product Manager")).toBe(jobTitleGroupKey("PRODUCT MANAGER")); + expect(jobTitleGroupKey("Product Manager")).toBe(jobTitleGroupKey("product manager")); + }); + + test("does NOT merge distinct seniority levels", () => { + expect(jobTitleGroupKey("Product Manager")).not.toBe(jobTitleGroupKey("Senior Product Manager")); + }); + + test("does NOT merge distinct roles that share a word", () => { + expect(jobTitleGroupKey("Product Manager")).not.toBe(jobTitleGroupKey("Product Marketing Manager")); + expect(jobTitleGroupKey("Data Scientist")).not.toBe(jobTitleGroupKey("Data Engineer")); + expect(jobTitleGroupKey("Engineer")).not.toBe(jobTitleGroupKey("Engineering Manager")); + }); + + test("does NOT merge a single role with a combined role sharing a word", () => { + expect(jobTitleGroupKey("Founder")).not.toBe(jobTitleGroupKey("CEO")); + expect(jobTitleGroupKey("CEO")).not.toBe(jobTitleGroupKey("Founder / CEO")); + expect(jobTitleGroupKey("Founder")).not.toBe(jobTitleGroupKey("Founder / CEO")); + }); + + test("blank/null input yields an empty key", () => { + expect(jobTitleGroupKey("")).toBe(""); + expect(jobTitleGroupKey(null)).toBe(""); + expect(jobTitleGroupKey(" ")).toBe(""); + }); + + test("treats common placeholder values as blank", () => { + expect(jobTitleGroupKey("N/A")).toBe(""); + expect(jobTitleGroupKey("na")).toBe(""); + expect(jobTitleGroupKey("None")).toBe(""); + expect(jobTitleGroupKey("-")).toBe(""); + }); +}); + +describe("canonicalJobTitleDisplay", () => { + test("returns the curated display for known equivalence groups", () => { + expect(canonicalJobTitleDisplay(jobTitleGroupKey("Founder & CEO"), "whatever")).toBe("Founder / CEO"); + expect(canonicalJobTitleDisplay(jobTitleGroupKey("Founder CEO"), "whatever")).toBe("Founder / CEO"); + }); + + test("falls back to the provided display when there is no explicit mapping", () => { + expect(canonicalJobTitleDisplay(jobTitleGroupKey("Product Manager"), "Product Manager")).toBe( + "Product Manager" + ); + }); +}); diff --git a/frontend/src/__tests__/memberFacets.test.js b/frontend/src/__tests__/memberFacets.test.js new file mode 100644 index 0000000..e352243 --- /dev/null +++ b/frontend/src/__tests__/memberFacets.test.js @@ -0,0 +1,131 @@ +import { computeMemberView, companyGroupKey, memberJobTitleGroupKey } from "../landing_page/landing_page_armor/memberFacets"; +import { jobTitleGroupKey } from "../landing_page/landing_page_armor/jobTitleNormalization"; + +function m(firstName, company, title) { + return { firstName, lastName: "Test", company, title, linkedin: "" }; +} + +const members = [ + m("Alice", "Anote", "Founder / CEO"), + m("Bob", " anote ", "Data Scientist"), // dup company, different case/whitespace + m("Carol", "OpenAI", "Founder & CEO"), // equivalent title, different company + m("Dave", "OpenAI", "Research Engineer"), + m("Erin", "", ""), // blank company/title — must be excluded from facets + m("Frank", "Google", "Product Manager"), + m("Grace", "Google", "Senior Product Manager"), // distinct seniority — must not merge +]; + +describe("computeMemberView — facet building", () => { + test("builds company facets across the full dataset with blank values excluded", () => { + const { companyFacets } = computeMemberView(members, {}); + const values = companyFacets.map((f) => f.display); + expect(values).toEqual(expect.arrayContaining(["Anote", "OpenAI", "Google"])); + expect(values).not.toContain(""); + }); + + test("treats N/A-style placeholder values as blank, excluding them from options", () => { + const withPlaceholders = [ + ...members, + m("Henry", "N/A", "Engineer"), + m("Ivy", "n/a", "NA"), + ]; + const { companyFacets, jobTitleFacets } = computeMemberView(withPlaceholders, {}); + expect(companyFacets.some((f) => f.display.toLowerCase() === "n/a")).toBe(false); + expect(jobTitleFacets.some((f) => f.display.toLowerCase() === "na")).toBe(false); + }); + + test("dedupes company casing/whitespace but keeps the combined count", () => { + const { companyFacets } = computeMemberView(members, {}); + const anote = companyFacets.find((f) => f.key === companyGroupKey("Anote")); + expect(anote.count).toBe(2); // Alice + Bob + }); + + test("does not merge distinct seniority levels in job title facets", () => { + const { jobTitleFacets } = computeMemberView(members, {}); + const pm = jobTitleFacets.find((f) => f.display === "Product Manager"); + const seniorPm = jobTitleFacets.find((f) => f.display === "Senior Product Manager"); + expect(pm.count).toBe(1); + expect(seniorPm.count).toBe(1); + }); + + test("merges equivalent job-title formatting into one canonical group with combined count", () => { + const { jobTitleFacets } = computeMemberView(members, {}); + const ceoGroup = jobTitleFacets.find((f) => f.display === "Founder / CEO"); + expect(ceoGroup.count).toBe(2); // Alice ("Founder / CEO") + Carol ("Founder & CEO") + }); +}); + +describe("computeMemberView — filtering (AND across sections, OR within a section)", () => { + test("filters by a single selected company", () => { + const { filteredMembers } = computeMemberView(members, { + selectedCompanyKeys: [companyGroupKey("Anote")], + }); + expect(filteredMembers.map((x) => x.firstName).sort()).toEqual(["Alice", "Bob"]); + }); + + test("OR logic within the company section", () => { + const { filteredMembers } = computeMemberView(members, { + selectedCompanyKeys: [companyGroupKey("Anote"), companyGroupKey("Google")], + }); + expect(filteredMembers.map((x) => x.firstName).sort()).toEqual(["Alice", "Bob", "Frank", "Grace"]); + }); + + test("AND logic between company and job title sections", () => { + const { filteredMembers } = computeMemberView(members, { + selectedCompanyKeys: [companyGroupKey("OpenAI")], + selectedJobTitleKeys: [jobTitleGroupKey("Founder / CEO")], + }); + expect(filteredMembers.map((x) => x.firstName)).toEqual(["Carol"]); + }); + + test("combines filters with the general search", () => { + const { filteredMembers } = computeMemberView(members, { + query: "data", + selectedCompanyKeys: [companyGroupKey("Anote")], + }); + expect(filteredMembers.map((x) => x.firstName)).toEqual(["Bob"]); + }); +}); + +describe("computeMemberView — faceted counts respect other active filters", () => { + test("company counts respect the selected job title (not the company selection itself)", () => { + const { companyFacets } = computeMemberView(members, { + selectedJobTitleKeys: [jobTitleGroupKey("Founder / CEO")], + }); + const anote = companyFacets.find((f) => f.key === companyGroupKey("Anote")); + const openai = companyFacets.find((f) => f.key === companyGroupKey("OpenAI")); + const google = companyFacets.find((f) => f.key === companyGroupKey("Google")); + expect(anote.count).toBe(1); // only Alice has "Founder / CEO" at Anote + expect(openai.count).toBe(1); // only Carol + expect(google).toBeUndefined(); // no Google member has that title, and it's not selected + }); + + test("job title counts respect the selected company (not the job title selection itself)", () => { + const { jobTitleFacets } = computeMemberView(members, { + selectedCompanyKeys: [companyGroupKey("Google")], + }); + const pm = jobTitleFacets.find((f) => f.display === "Product Manager"); + const seniorPm = jobTitleFacets.find((f) => f.display === "Senior Product Manager"); + const ceo = jobTitleFacets.find((f) => f.display === "Founder / CEO"); + expect(pm.count).toBe(1); + expect(seniorPm.count).toBe(1); + expect(ceo).toBeUndefined(); + }); + + test("a selected option remains visible (with count 0) even if another filter zeroes it out", () => { + const { companyFacets } = computeMemberView(members, { + selectedCompanyKeys: [companyGroupKey("Google")], + selectedJobTitleKeys: [jobTitleGroupKey("Founder / CEO")], // no Google member has this title + }); + const google = companyFacets.find((f) => f.key === companyGroupKey("Google")); + expect(google).toBeDefined(); + expect(google.count).toBe(0); + }); +}); + +describe("memberJobTitleGroupKey", () => { + test("derives the same key as jobTitleGroupKey for a member's raw title", () => { + const member = m("X", "Y", "Founder & CEO"); + expect(memberJobTitleGroupKey(member)).toBe(jobTitleGroupKey("Founder & CEO")); + }); +}); diff --git a/frontend/src/landing_page/landing_page_armor/ArmorMembers.js b/frontend/src/landing_page/landing_page_armor/ArmorMembers.js index 65da91d..bbcb090 100644 --- a/frontend/src/landing_page/landing_page_armor/ArmorMembers.js +++ b/frontend/src/landing_page/landing_page_armor/ArmorMembers.js @@ -1,25 +1,78 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import classNames from "classnames"; import SEO from "../../util/SEO"; import ShareButton from "./ShareButton"; +import FilterDrawer from "./FilterDrawer"; +import useDebouncedValue from "../../util/useDebouncedValue"; +import { + computeMemberView, + companyGroupKey, + memberJobTitleGroupKey, + normalizeValue, +} from "./memberFacets"; +import { normalizeSeparators, jobTitleGroupKey } from "./jobTitleNormalization"; const SHEET_ID = "1IREd2vCxo7rDGOoDUYtzoywVGLqqUFCsu3qGsA0HsMc"; const API_KEY = "AIzaSyCjxXVDGAolugKgrTXpJ0HmAjL0lLxLN1E"; const SHEET_NAME = "Sheet-1"; +function parseCsvParam(searchParams, name) { + const raw = searchParams.get(name); + if (!raw) return []; + return raw + .split(",") + .map((v) => v.trim()) + .filter(Boolean); +} + +function parsePageParam(searchParams) { + const raw = parseInt(searchParams.get("page"), 10); + return Number.isFinite(raw) && raw >= 1 ? raw : 1; +} + +// Resolves the display label for a selected key, checking the current facet +// list first and falling back to a fresh lookup against the full dataset. +// Computed synchronously during render (not via effect) so URL/chip labels +// never show a raw internal key, even on the very first render after a +// URL-hydrated filter selection. +function displayForKey(key, facets, members, kind) { + const found = facets.find((f) => f.key === key); + if (found) return found.display; + const match = members.find((m) => + kind === "company" ? companyGroupKey(m.company) === key : memberJobTitleGroupKey(m) === key + ); + if (match) return kind === "company" ? normalizeValue(match.company) : normalizeSeparators(match.title); + return key; +} + function ArmorMembers() { const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [query, setQuery] = useState(""); - const [page, setPage] = useState(1); + const [searchParams, setSearchParams] = useSearchParams(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const [rawQuery, setRawQuery] = useState(() => searchParams.get("search") || ""); + const [selectedCompanyKeys, setSelectedCompanyKeys] = useState(() => + parseCsvParam(searchParams, "company").map((v) => companyGroupKey(v)) + ); + const [selectedJobTitleKeys, setSelectedJobTitleKeys] = useState(() => + parseCsvParam(searchParams, "jobTitle").map((v) => jobTitleGroupKey(v)) + ); + const [page, setPage] = useState(() => parsePageParam(searchParams)); const pageSize = 10; + const debouncedQuery = useDebouncedValue(rawQuery, 250); + const isFirstFilterRun = useRef(true); + useEffect(() => { + const controller = new AbortController(); + const fetchData = async () => { try { const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${encodeURIComponent(SHEET_NAME)}?key=${API_KEY}`; - const res = await fetch(url); + const res = await fetch(url, { signal: controller.signal }); const data = await res.json(); if (!data.values || data.values.length < 2) { @@ -29,7 +82,7 @@ function ArmorMembers() { const headers = data.values[0]; const rows = data.values.slice(1); - const parsed = rows.map(row => { + const parsed = rows.map((row) => { const obj = {}; headers.forEach((header, idx) => { obj[header] = row[idx] || ""; @@ -45,29 +98,96 @@ function ArmorMembers() { setMembers(parsed); } catch (err) { - setError(err.message); + if (err.name !== "AbortError") { + setError(err.message); + } } finally { setLoading(false); } }; fetchData(); + + return () => controller.abort(); }, []); - const filtered = members.filter((m) => { - const q = query.toLowerCase(); - return ( - m.firstName.toLowerCase().includes(q) || - m.lastName.toLowerCase().includes(q) || - m.company.toLowerCase().includes(q) || - m.title.toLowerCase().includes(q) - ); - }); + const { filteredMembers, companyFacets, jobTitleFacets } = useMemo( + () => + computeMemberView(members, { + query: debouncedQuery, + selectedCompanyKeys, + selectedJobTitleKeys, + }), + [members, debouncedQuery, selectedCompanyKeys, selectedJobTitleKeys] + ); + + const pages = Math.ceil(filteredMembers.length / pageSize); + const slice = filteredMembers.slice((page - 1) * pageSize, page * pageSize); + + const activeFilterCount = selectedCompanyKeys.length + selectedJobTitleKeys.length; + const hasActiveFilters = activeFilterCount > 0; + + const selectedCompanyChips = useMemo( + () => selectedCompanyKeys.map((key) => ({ key, display: displayForKey(key, companyFacets, members, "company") })), + [selectedCompanyKeys, companyFacets, members] + ); + const selectedJobTitleChips = useMemo( + () => selectedJobTitleKeys.map((key) => ({ key, display: displayForKey(key, jobTitleFacets, members, "jobTitle") })), + [selectedJobTitleKeys, jobTitleFacets, members] + ); + + // Reset to page 1 whenever a filter changes — but not on the initial mount, + // where `page` may have been hydrated from the URL. + useEffect(() => { + if (isFirstFilterRun.current) { + isFirstFilterRun.current = false; + return; + } + setPage(1); + }, [debouncedQuery, selectedCompanyKeys, selectedJobTitleKeys]); + + // Clamp page into range once the filtered dataset (and therefore `pages`) + // is known, e.g. a shared URL with ?page=999 against a narrower filter. + useEffect(() => { + if (pages > 0 && page > pages) { + setPage(pages); + } + }, [pages, page]); + + // Keep the URL in sync with search/filters/page, omitting empty params. + // Skipped while the dataset hasn't loaded yet — before then, facet display + // labels for URL-hydrated selections can't be resolved, so syncing early + // would briefly overwrite the URL with a lower-quality fallback (the raw + // internal key) instead of the proper display casing. + useEffect(() => { + if (loading) return; + const params = {}; + if (debouncedQuery) params.search = debouncedQuery; + if (selectedCompanyChips.length > 0) { + params.company = selectedCompanyChips.map((c) => c.display).join(","); + } + if (selectedJobTitleChips.length > 0) { + params.jobTitle = selectedJobTitleChips.map((c) => c.display).join(","); + } + if (page > 1) params.page = String(page); + setSearchParams(params, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQuery, selectedCompanyChips, selectedJobTitleChips, page, loading]); + + const toggleCompany = (key) => { + setSelectedCompanyKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + }; + const toggleJobTitle = (key) => { + setSelectedJobTitleKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + }; - const pages = Math.ceil(filtered.length / pageSize); - const slice = filtered.slice((page - 1) * pageSize, page * pageSize); + const clearAllFilters = () => { + setRawQuery(""); + setSelectedCompanyKeys([]); + setSelectedJobTitleKeys([]); + }; - if (loading) return
Loading...
; + if (loading) return
Loading...
; if (error) return
Error: {error}
; return ( @@ -77,110 +197,199 @@ function ArmorMembers() { description="Browse and connect with AI builders, researchers, founders, and operators across the Anote community." path="/community/members" /> -
-

Community Members

- { - setQuery(e.target.value); - setPage(1); - }} - className="w-full sm:w-72 px-4 py-2 rounded-md bg-gray-800 border border-gray-700 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
-
- - - - - - - - - - - - - {slice.map((member, i) => ( - - - - - - - - +
+
+

Community Members

+
+ +
+ + setRawQuery(e.target.value)} + className="w-full sm:w-72 px-4 py-2 rounded-md bg-gray-800 border border-gray-700 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ + {(selectedCompanyChips.length > 0 || selectedJobTitleChips.length > 0) && ( +
+ {selectedCompanyChips.map((chip) => ( + + ))} + {selectedJobTitleChips.map((chip) => ( + ))} -
-
First NameLast NameCompanyJob TitleLinkedInShare
{member.firstName}{member.lastName}{member.company}{member.title} - {member.linkedin ? ( - - View ↗ - - ) : ( - "-" - )} - - -
+
+ )} - {pages > 1 && ( + setIsDrawerOpen(false)} + onClearAll={clearAllFilters} + hasActiveFilters={hasActiveFilters} + companyFacets={companyFacets} + jobTitleFacets={jobTitleFacets} + selectedCompanyKeys={selectedCompanyKeys} + selectedJobTitleKeys={selectedJobTitleKeys} + onToggleCompany={toggleCompany} + onToggleJobTitle={toggleJobTitle} + /> + + {filteredMembers.length === 0 ? ( +
+

No members match the selected filters.

+ +
+ ) : ( +
+ + + + + + + + + + + + + {slice.map((member, i) => ( + + + + + + + + + ))} + +
First NameLast NameCompanyJob TitleLinkedInShare
{member.firstName}{member.lastName}{member.company}{member.title} + {member.linkedin ? ( + + View ↗ + + ) : ( + "-" + )} + + +
+
+ )} + + {filteredMembers.length > 0 && (
- Showing {filtered.length} members + Showing {filteredMembers.length} member{filteredMembers.length === 1 ? "" : "s"}
-
- - -
- Page - { - let val = parseInt(e.target.value); - if (!isNaN(val) && val >= 1 && val <= pages) { - setPage(val); - } - }} - className="w-16 text-center py-1 px-2 rounded-md bg-gray-800 border border-gray-600 text-white" - /> - of {pages} -
+ {pages > 1 && ( +
+ - -
+
+ + { + let val = parseInt(e.target.value); + if (!isNaN(val) && val >= 1 && val <= pages) { + setPage(val); + } + }} + className="w-16 text-center py-1 px-2 rounded-md bg-gray-800 border border-gray-600 text-white" + /> + of {pages} +
+ + +
+ )}
)} @@ -188,4 +397,3 @@ function ArmorMembers() { } export default ArmorMembers; - diff --git a/frontend/src/landing_page/landing_page_armor/FilterDrawer.js b/frontend/src/landing_page/landing_page_armor/FilterDrawer.js new file mode 100644 index 0000000..04e1be5 --- /dev/null +++ b/frontend/src/landing_page/landing_page_armor/FilterDrawer.js @@ -0,0 +1,98 @@ +import React, { useEffect, useRef } from "react"; +import FilterSection from "./FilterSection"; +import useFocusTrap from "../../util/useFocusTrap"; + +export default function FilterDrawer({ + isOpen, + onClose, + onClearAll, + hasActiveFilters, + companyFacets, + jobTitleFacets, + selectedCompanyKeys, + selectedJobTitleKeys, + onToggleCompany, + onToggleJobTitle, +}) { + const panelRef = useRef(null); + useFocusTrap(panelRef, isOpen); + + useEffect(() => { + if (!isOpen) return undefined; + const handleKeyDown = (e) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+ + ); +} diff --git a/frontend/src/landing_page/landing_page_armor/FilterSection.js b/frontend/src/landing_page/landing_page_armor/FilterSection.js new file mode 100644 index 0000000..62e72cb --- /dev/null +++ b/frontend/src/landing_page/landing_page_armor/FilterSection.js @@ -0,0 +1,102 @@ +import React, { useId, useMemo, useState } from "react"; + +// One collapsible section of the filter drawer (Company or Job Title): +// a search-within box plus a scrollable list of checkboxes with counts. +export default function FilterSection({ + title, + searchPlaceholder, + options, + selectedKeys, + onToggle, +}) { + const [expanded, setExpanded] = useState(true); + const [query, setQuery] = useState(""); + const headingId = useId(); + const searchId = useId(); + + const filteredOptions = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter((opt) => opt.display.toLowerCase().includes(q)); + }, [options, query]); + + const selectedCount = selectedKeys.length; + + return ( +
+ + + {expanded && ( +
+ + setQuery(e.target.value)} + className="w-full px-3 py-2 mb-2 rounded-md bg-gray-800 border border-gray-700 text-white placeholder-gray-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
    + {filteredOptions.length === 0 && ( +
  • No matches.
  • + )} + {filteredOptions.map((opt) => { + const checkboxId = `${searchId}-${opt.key}`; + const isSelected = selectedKeys.includes(opt.key); + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/landing_page/landing_page_armor/jobTitleNormalization.js b/frontend/src/landing_page/landing_page_armor/jobTitleNormalization.js new file mode 100644 index 0000000..bca61e0 --- /dev/null +++ b/frontend/src/landing_page/landing_page_armor/jobTitleNormalization.js @@ -0,0 +1,86 @@ +// Job-title normalization for the Members filter drawer. +// +// Goal: merge titles that differ only in punctuation/formatting (the SAME set +// of words joined differently), while never merging titles that use different +// words — even if they look related. Normalization is used only for grouping +// and filtering; the member's original stored title is never overwritten. +// +// How it works: +// 1. `cleanTitle` — trims/collapses whitespace only. Never touches words. +// 2. `normalizeSeparators` — rewrites punctuation/word separators (&, +, ",", +// the word "and") into a single canonical " / " separator. This is safe +// to apply unconditionally because it only changes HOW words are joined, +// never WHICH words are present — so "Product Manager" and +// "Senior Product Manager" can never collide (different word sets), +// but "Founder & CEO" and "Founder and CEO" correctly collapse to the +// same separator-normalized string. +// 3. `jobTitleGroupKey` — lowercases the separator-normalized string. This +// is the identity used for grouping/matching, so casing differences +// ("CEO" vs "ceo") never produce duplicate groups. +// 4. CANONICAL_JOB_TITLE_MAP — an explicit, hand-maintained lookup from +// group key -> preferred display label, for cases a generic separator +// swap can't infer on its own (e.g. "Founder CEO" with no punctuation +// at all still means the combined role). Add new rows here as new +// equivalent variants are discovered in the data — each entry should +// be a deliberate judgment call, not a pattern match. +// +// Explicitly NOT done here: substring matching, fuzzy/similarity merging, or +// collapsing distinct roles/seniority levels (e.g. "Product Manager" and +// "Senior Product Manager" are different word sets and are never merged). + +export function cleanTitle(raw) { + return (raw || "").toString().replace(/\s+/g, " ").trim(); +} + +const SEPARATOR_PATTERN = /\s*(?:&|\+|,)\s*/g; +const AND_PATTERN = /\s+and\s+/gi; +const REPEATED_SLASH_PATTERN = /(?:\s*\/\s*)+/g; + +export function normalizeSeparators(raw) { + const cleaned = cleanTitle(raw); + if (!cleaned) return ""; + return cleaned + .replace(SEPARATOR_PATTERN, " / ") + .replace(AND_PATTERN, " / ") + .replace(REPEATED_SLASH_PATTERN, " / ") + .replace(/\s+/g, " ") + .trim(); +} + +// Placeholder values people type instead of leaving the field blank — +// treated the same as blank/null for filter-option purposes. Checked against +// the raw cleaned string too (before separator normalization), since "N/A" +// would otherwise become "n / a" and no longer match "na". +const PLACEHOLDER_VALUES = new Set(["n/a", "na", "none", "null", "undefined", "-", "--", "."]); + +export function jobTitleGroupKey(raw) { + const rawLower = cleanTitle(raw).toLowerCase(); + if (PLACEHOLDER_VALUES.has(rawLower)) return ""; + const key = normalizeSeparators(raw).toLowerCase(); + return PLACEHOLDER_VALUES.has(key) ? "" : key; +} + +// key (from jobTitleGroupKey) -> canonical display label. +// Each entry documents *why* it's an equivalence, not just a pattern. +export const CANONICAL_JOB_TITLE_MAP = { + // Founder + CEO in any punctuation/order — same combined role. + "founder / ceo": "Founder / CEO", + "ceo / founder": "Founder / CEO", + "founder ceo": "Founder / CEO", // bare space, no punctuation at all + + // Co-Founder is treated as its own distinct role from Founder (implies + // multiple founders), so it gets its own canonical bucket rather than + // merging into "Founder / CEO". + "co-founder / ceo": "Co-Founder / CEO", + "co-founder ceo": "Co-Founder / CEO", + + "president / ceo": "President / CEO", + "president ceo": "President / CEO", +}; + +// Given a group key and a fallback display (typically the most common +// separator-normalized casing seen in the dataset for that key), return the +// label to show in the UI. +export function canonicalJobTitleDisplay(key, fallbackDisplay) { + return CANONICAL_JOB_TITLE_MAP[key] || fallbackDisplay; +} diff --git a/frontend/src/landing_page/landing_page_armor/memberFacets.js b/frontend/src/landing_page/landing_page_armor/memberFacets.js new file mode 100644 index 0000000..ff7ba97 --- /dev/null +++ b/frontend/src/landing_page/landing_page_armor/memberFacets.js @@ -0,0 +1,158 @@ +// Pure filtering + faceted-count engine for the Members page. +// +// The whole member dataset is already loaded into the browser in one shot +// (see ArmorMembers.js), so all of this runs against the complete dataset, +// not just the currently visible page. +import { jobTitleGroupKey, normalizeSeparators, canonicalJobTitleDisplay } from "./jobTitleNormalization"; + +export function normalizeValue(value) { + return (value || "").toString().trim(); +} + +export function normalizeForCompare(value) { + return normalizeValue(value).toLowerCase(); +} + +export function matchesGeneralSearch(member, query) { + const q = normalizeForCompare(query); + if (!q) return true; + return ( + normalizeForCompare(member.firstName).includes(q) || + normalizeForCompare(member.lastName).includes(q) || + normalizeForCompare(member.company).includes(q) || + normalizeForCompare(member.title).includes(q) + ); +} + +// Placeholder values people type instead of leaving the field blank — +// treated the same as blank/null for filter-option purposes. +const PLACEHOLDER_VALUES = new Set(["n/a", "na", "none", "null", "undefined", "-", "--", "."]); + +export function companyGroupKey(rawCompany) { + const normalized = normalizeForCompare(rawCompany); + return PLACEHOLDER_VALUES.has(normalized) ? "" : normalized; +} + +export function memberJobTitleGroupKey(member) { + return jobTitleGroupKey(member.title); +} + +// Builds { key -> count } style facet groups from a list of members, using +// caller-supplied key/display functions. Shared by company and job-title +// faceting so the "pick the most common casing as the display label" logic +// isn't duplicated. +function buildFacetGroups(members, { getKey, getDisplayCandidate, getCanonicalDisplay }) { + const keyToVariantCounts = new Map(); // key -> Map(displayVariant -> count) + + members.forEach((member) => { + const key = getKey(member); + if (!key) return; + const variant = getDisplayCandidate(member); + if (!keyToVariantCounts.has(key)) { + keyToVariantCounts.set(key, new Map()); + } + const variantCounts = keyToVariantCounts.get(key); + variantCounts.set(variant, (variantCounts.get(variant) || 0) + 1); + }); + + const groups = []; + keyToVariantCounts.forEach((variantCounts, key) => { + let bestVariant = null; + let bestCount = -1; + let total = 0; + variantCounts.forEach((count, variant) => { + total += count; + if (count > bestCount) { + bestCount = count; + bestVariant = variant; + } + }); + const display = getCanonicalDisplay ? getCanonicalDisplay(key, bestVariant) : bestVariant; + groups.push({ key, display, count: total }); + }); + + return groups; +} + +function buildCompanyGroups(members) { + return buildFacetGroups(members, { + getKey: (m) => companyGroupKey(m.company), + getDisplayCandidate: (m) => normalizeValue(m.company), + }); +} + +function buildJobTitleGroups(members) { + return buildFacetGroups(members, { + getKey: (m) => memberJobTitleGroupKey(m), + getDisplayCandidate: (m) => normalizeSeparators(m.title), + getCanonicalDisplay: canonicalJobTitleDisplay, + }); +} + +function sortFacetGroups(groups) { + return [...groups].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + return a.display.localeCompare(b.display, undefined, { sensitivity: "base" }); + }); +} + +// Selected values are matched against members via each group's key, so +// selections stay correct regardless of the exact display casing. +function memberMatchesSelection(memberKey, selectedKeys) { + if (!selectedKeys || selectedKeys.length === 0) return true; + return selectedKeys.includes(memberKey); +} + +// Computes the filtered member list plus faceted Company/Job Title option +// lists in one pass. Facets are computed against "every other active filter +// except the section's own selection" (standard faceted-search behavior), +// and any currently-selected value is kept visible even if its count under +// the other filters drops to zero. +export function computeMemberView(members, { query, selectedCompanyKeys = [], selectedJobTitleKeys = [] } = {}) { + const searchFiltered = members.filter((m) => matchesGeneralSearch(m, query)); + + const companyFacetBase = searchFiltered.filter((m) => + memberMatchesSelection(memberJobTitleGroupKey(m), selectedJobTitleKeys) + ); + const jobTitleFacetBase = searchFiltered.filter((m) => + memberMatchesSelection(companyGroupKey(m.company), selectedCompanyKeys) + ); + + let companyFacets = sortFacetGroups(buildCompanyGroups(companyFacetBase)); + let jobTitleFacets = sortFacetGroups(buildJobTitleGroups(jobTitleFacetBase)); + + companyFacets = companyFacets.filter( + (g) => g.count > 0 || selectedCompanyKeys.includes(g.key) + ); + jobTitleFacets = jobTitleFacets.filter( + (g) => g.count > 0 || selectedJobTitleKeys.includes(g.key) + ); + + // A selected value might not appear at all in the current facet base + // (e.g. its count truly is zero and it wasn't in buildCompanyGroups because + // no member in companyFacetBase has that company). Backfill it from the + // FULL dataset so it never silently disappears from the panel. + companyFacets = backfillMissingSelections(companyFacets, selectedCompanyKeys, buildCompanyGroups(members)); + jobTitleFacets = backfillMissingSelections(jobTitleFacets, selectedJobTitleKeys, buildJobTitleGroups(members)); + + const filteredMembers = searchFiltered.filter( + (m) => + memberMatchesSelection(companyGroupKey(m.company), selectedCompanyKeys) && + memberMatchesSelection(memberJobTitleGroupKey(m), selectedJobTitleKeys) + ); + + return { filteredMembers, companyFacets, jobTitleFacets }; +} + +function backfillMissingSelections(facets, selectedKeys, fullDatasetGroups) { + const present = new Set(facets.map((f) => f.key)); + const missing = selectedKeys.filter((key) => !present.has(key)); + if (missing.length === 0) return facets; + + const fullByKey = new Map(fullDatasetGroups.map((g) => [g.key, g])); + const backfilled = missing.map((key) => { + const known = fullByKey.get(key); + return known ? { ...known, count: 0 } : { key, display: key, count: 0 }; + }); + return sortFacetGroups([...facets, ...backfilled]); +} diff --git a/frontend/src/util/useDebouncedValue.js b/frontend/src/util/useDebouncedValue.js new file mode 100644 index 0000000..242febe --- /dev/null +++ b/frontend/src/util/useDebouncedValue.js @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +// Returns `value`, but only updates after it has been stable for `delayMs`. +// Used to avoid re-filtering/re-rendering on every keystroke. +export default function useDebouncedValue(value, delayMs = 250) { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/frontend/src/util/useFocusTrap.js b/frontend/src/util/useFocusTrap.js new file mode 100644 index 0000000..01c4e03 --- /dev/null +++ b/frontend/src/util/useFocusTrap.js @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react"; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +// Traps Tab/Shift+Tab focus within `containerRef` while `isActive` is true, +// and restores focus to whatever was focused before activation once it ends. +export default function useFocusTrap(containerRef, isActive) { + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!isActive) return undefined; + + previouslyFocused.current = document.activeElement; + const container = containerRef.current; + + const focusFirst = () => { + const focusable = container?.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusable && focusable.length > 0) { + focusable[0].focus(); + } + }; + focusFirst(); + + const handleKeyDown = (e) => { + if (e.key !== "Tab" || !container) return; + const focusable = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + if (previouslyFocused.current && previouslyFocused.current.focus) { + previouslyFocused.current.focus(); + } + }; + }, [isActive, containerRef]); +}