Skip to content

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.

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.

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

Post type and taxonomies (inc/cpt.php):

  • team CPT, rewrite slug people, supports title/editor/thumbnail/excerpt.
  • positions — non-hierarchical, rewrite people/position (cpt.php:341).
  • services — hierarchical, rewrite people/practice-area/service (cpt.php:376).
  • sectors — hierarchical, rewrite people/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]

Nothing environment-specific. Editor-facing controls only:

  • Global Options ACF options page (registered in inc/acf.php:18) — service_filter_options limits which service terms appear in the dropdown.
  • Per-block ACF fields service_filter_source (automatic | manual) and service_filter_options (block-people-grid.php:182-195). automatic uses all non-empty services terms; manual uses 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.
  • last_name must be populated on every person. Ordering is meta_key => last_name, so a person with no last_name value is dropped from the default listing query entirely.
  • first_name/last_name are separate from post_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 a service post without renaming the matching services term silently drops the link — no error, it just disappears.
  • custom_add_data_number_to_pagination() is declared twice — inside people_filter() (functions.php:606) and inside block-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-block partial 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.
  • REGEXP on user input. name_search passes through sanitize_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 at functions.php:370 maps ^people/page/N onto the people page. If that page’s slug changes, both must change.
  • orderby_position is a custom query var, not core. It works only because order_team_by_position() looks for it (functions.php:1368-1380).
  • backfill_seniority_order_for_existing_team() is hooked to init (functions.php:1684) and queries all team posts with posts_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.
  • New filter dimension → add the <select> to block-people-grid.php, add the matching $_POST branch in people_filter(), and keep the existing array_map('intval', ...) treatment of term IDs.
  • Changing card markup → update both partials/profile-block.php and the string-built card in people_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 rewrite term_order meta.
  • Rebuild assets from wp-content/themes/bpcollins with npx gulp watch (or a one-off gulp build). The browser loads assets/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.