People directory and profiles
The /people/ directory lists every solicitor and staff member, filterable by
position, service and sector, with a name autocomplete. Each person also has a
single profile page carrying contact details, specialisms and quotes.
Why it exists
Section titled “Why it exists”A law firm’s directory is its shop window. The filtering exists because the firm has many fee earners across several practice areas, and clients arrive either knowing a name or knowing a problem — the name autocomplete and the position/service/sector dropdowns serve those two journeys respectively.
The services and sectors filters were deliberately moved to taxonomy-based
queries so front-end counts match what wp-admin shows — see the comment at
wp-content/themes/bpcollins/functions.php:441. Earlier code filtered on an ACF
relationship meta field and the two disagreed.
Entry points
Section titled “Entry points”| Path | What it does |
|---|---|
wp-content/themes/bpcollins/blocks/people-grid/block-people-grid.php |
The directory block. Renders the filter form and the first (server-side) page of results |
wp-content/themes/bpcollins/functions.php:383 |
people_filter() — the peopleFilter AJAX handler that returns every filtered/paginated result set |
wp-content/themes/bpcollins/blocks/people-grid/block-people-grid.js |
Autocomplete, filter change → AJAX, pagination interception |
wp-content/themes/bpcollins/single-team.php |
A single person’s profile page |
wp-content/themes/bpcollins/taxonomy-positions.php, taxonomy-services.php, taxonomy-sectors.php |
Pre-filtered directory views, e.g. /people/practice-area/service/<slug> |
wp-content/themes/bpcollins/inc/cpt.php:151 |
team post type, rewrite slug people |
wp-content/themes/bpcollins/blocks/team-filters/block-team-filters.php |
Separate, smaller filter block used on some landing pages |
How it works
Section titled “How it works”Post type and taxonomies (inc/cpt.php):
teamCPT, rewrite slugpeople, supports title/editor/thumbnail/excerpt.positions— non-hierarchical, rewritepeople/position(cpt.php:341).services— hierarchical, rewritepeople/practice-area/service(cpt.php:376).sectors— hierarchical, rewritepeople/practice-area/sector(cpt.php:409).
Note there are two things called “services”: the service post type
(the marketing pages under /services/) and the services taxonomy on
team. They are joined by slug or title, never by ID — see Invariants.
First render. block-people-grid.php runs two queries: $people (up to 200
members, for the autocomplete dataset) and $people_paged (12 per page, ordered
by the last_name ACF meta value). On a taxonomy archive it adds a tax_query
for the queried term (block-people-grid.php:86-117) and emits a matching
hidden input (is_tax_positions / is_tax_services / is_tax_sectors) so the
AJAX handler can preserve that constraint.
The autocomplete dataset is JSON-encoded into a data-team-members attribute on
a hidden div (block-people-grid.php:222-240), escaped with esc_attr(). The JS
parses it client-side; no request is made while typing.
Filtering. Every filter change, name search and pagination click posts the
#filter form to wp-admin/admin-ajax.php with action=peopleFilter and
replaces the inner HTML of #response (block-people-grid.js:318-424).
people_filter() rebuilds the query and re-renders the whole results region
including its own pagination (functions.php:600-646), using the
/people/page/%#% link format.
Name search ordering. When name_search is set, people_filter() drops the
default meta_key/orderby (functions.php:423-425), queries first_name and
last_name with a REGEXP “starts with” comparison, then re-sorts the results
in PHP with a three-tier usort: first-name matches, then last-name matches,
then others (functions.php:493-534). The JS autocomplete duplicates this exact
ordering (block-people-grid.js:141-172) so the dropdown and the filtered grid
agree.
Seniority ordering. Position terms carry a term_order term meta.
order_team_by_position_clauses() (functions.php:1387) injects a derived-table
JOIN so team queries order by the minimum position term_order, then title. It
applies to the wp-admin team list and to any query passing a truthy
orderby_position var. Separately, sync_seniority_order_to_people()
(functions.php:1605) copies the position’s order onto each person as a
seniority_order post meta on ACF save, which is what orders the ACF
featured_people relationship picker (functions.php:1641).
Profile page. single-team.php renders the hero block, then a contact bar
(the telephone field is split on | for multiple numbers,
single-team.php:43-53), the_content(), and a side column of specialist
services and sectors. Services link to the matching service post found by
title via get_page_by_title(), and only render if page depth < 2
(single-team.php:110-127). Sectors link to a sector post found by path
(single-team.php:146-150). Data access goes through BPTeam
(inc/classes/BPTeam.php), which wraps the ACF fields thumbnail_image,
role, team_telephone_number, team_email_address, team_social_links and
quotes. If a person has neither services nor sectors, the side column falls
back to the three latest posts (single-team.php:163-188).
Person JSON-LD for the profile is added as a Yoast graph piece — see the SEO and structured data page.
flowchart TD A[/people/ page with people-grid block/] --> B[server render: 12 people by last_name] A --> C[hidden div: JSON of up to 200 people] C --> D[typing → client-side autocomplete only] D -->|click a name| E[navigate to profile] D -->|Enter or search button| F[POST admin-ajax peopleFilter] B -->|filter change or pagination| F F --> G[people_filter rebuilds WP_Query] G --> H[returns full results HTML plus pagination] H --> I[JS replaces #response innerHTML]Configuration
Section titled “Configuration”Nothing environment-specific. Editor-facing controls only:
- Global Options ACF options page (registered in
inc/acf.php:18) —service_filter_optionslimits which service terms appear in the dropdown. - Per-block ACF fields
service_filter_source(automatic|manual) andservice_filter_options(block-people-grid.php:182-195).automaticuses all non-emptyservicesterms;manualuses the chosen list, falling back to the global list. - ACF fields on each person:
first_name,last_name(both required for ordering and search to work),role,thumbnail_image.
Invariants and gotchas
Section titled “Invariants and gotchas”last_namemust be populated on every person. Ordering ismeta_key => last_name, so a person with nolast_namevalue is dropped from the default listing query entirely.first_name/last_nameare separate frompost_title. Search and autocomplete use the ACF fields; the visible name comes from the title. They drift if an editor renames the post without updating the fields.- Services on a person are terms; services in the nav are posts. They are
joined by title (
single-team.php:112) or by slug (functions.php:983-998). Renaming aservicepost without renaming the matchingservicesterm silently drops the link — no error, it just disappears. custom_add_data_number_to_pagination()is declared twice — insidepeople_filter()(functions.php:606) and insideblock-people-grid.php:342. Both are global declarations. Only one path runs per request today; making both run in one request would be a fatal redeclare.- Card markup exists twice. The
profile-blockpartial renders the first page;people_filter()builds the same card by string concatenation (functions.php:556-591). Change one, change both, or filtered results will not match the initial view. REGEXPon user input.name_searchpasses throughsanitize_text_field()and is then used as a regex fragment (functions.php:410-419). Regex metacharacters produce odd results rather than an error.- Pagination URL format is hardcoded.
people_filter()emits/people/page/%#%, and the rewrite atfunctions.php:370maps^people/page/Nonto thepeoplepage. If that page’s slug changes, both must change. orderby_positionis a custom query var, not core. It works only becauseorder_team_by_position()looks for it (functions.php:1368-1380).backfill_seniority_order_for_existing_team()is hooked toinit(functions.php:1684) and queries all team posts withposts_per_page => -1. Its own comment says “Run once then remove” — it was not removed, so it appears to run on every request, admin and front end.
Changing it safely
Section titled “Changing it safely”- New filter dimension → add the
<select>toblock-people-grid.php, add the matching$_POSTbranch inpeople_filter(), and keep the existingarray_map('intval', ...)treatment of term IDs. - Changing card markup → update both
partials/profile-block.phpand the string-built card inpeople_filter(). - Changing ordering → decide which of these it affects: the initial query
(
block-people-grid.php:22-30), the AJAX query (functions.php:389-400), the JS autocomplete sort (block-people-grid.js:156-172), the PHP re-sort (functions.php:493). They are independent of each other. - After touching position ordering, use the Reset Position Order link on the
team admin list (added at
functions.php:1505) to rewriteterm_ordermeta. - Rebuild assets from
wp-content/themes/bpcollinswithnpx gulp watch(or a one-off gulp build). The browser loadsassets/js/people-grid/block-people-grid.min.js, not the source file, so an unbuilt change looks like “my edit did nothing”. - Deliberately not abstracted: the duplicated card markup and the duplicated sort logic, because one path builds a string and the other echoes a template. Unifying them is a behaviour-affecting change, not a tidy-up — treat it as such.
- Manual verification (there is no test suite): load
/people/, filter by a position, page to 2, then use the name search; then load/people/practice-area/service/<slug>and confirm the taxonomy constraint survives a filter change.
None. This repository has no test runner, no test directory, and
bitbucket-pipelines.yml only deploys — it runs no tests. All verification is
manual in a browser.