Populate subprocessor filters from server facets

The subprocessors toolbar derived its category and region filter
options client-side from a second, unfiltered subprocessors(first: 250)
fetch, shipping up to 250 rows purely to compute two small facet sets
and silently capping the options at that limit.

Expose subprocessorCategories and subprocessorCountries on TrustCenter,
each backed by a DISTINCT query over the organization's third parties
scoped to show_on_trust_center, and read them directly in the toolbar.
The page now issues one filtered list query plus two tiny arrays, and
the options only ever include values that can actually return results.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-08 10:24:06 -04:00
parent 1e946d065c
commit 52b6ccac55
7 changed files with 181 additions and 27 deletions

View File

@@ -19,16 +19,16 @@ import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"
import { PageHeader } from "#/components/PageHeader/PageHeader";
import { SubprocessorCategorySection } from "./_components/SubprocessorCategorySection";
import type { SubprocessorsPage_query$key } from "./__generated__/SubprocessorsPage_query.graphql";
import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql";
import type { SubprocessorsPageRefetchQuery } from "./__generated__/SubprocessorsPageRefetchQuery.graphql";
import type { SubprocessorNode } from "./_components/SubprocessorCategorySection";
import { SubprocessorCategorySection } from "./_components/SubprocessorCategorySection";
import { SubprocessorsEmpty } from "./_components/SubprocessorsEmpty";
import { SubprocessorsToolbar } from "./_components/SubprocessorsToolbar";
import { groupByCategory } from "./_lib/groupByCategory";
import { toQueryVariables } from "./_lib/toQueryVariables";
import { useSubprocessorFilters } from "./_lib/useSubprocessorFilters";
import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql";
import type { SubprocessorsPageRefetchQuery } from "./__generated__/SubprocessorsPageRefetchQuery.graphql";
import type { SubprocessorsPage_query$key } from "./__generated__/SubprocessorsPage_query.graphql";
export const subprocessorsPageQuery = graphql`
query SubprocessorsPageQuery($query: String, $category: SubprocessorCategory, $country: CountryCode) {

View File

@@ -15,11 +15,11 @@
import { useEffect, useRef } from "react";
import { useQueryLoader } from "react-relay";
import { SubprocessorsPage, subprocessorsPageQuery } from "./SubprocessorsPage";
import { SubprocessorsPageSkeleton } from "./SubprocessorsPageSkeleton";
import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql";
import { toQueryVariables } from "./_lib/toQueryVariables";
import { useSubprocessorFilters } from "./_lib/useSubprocessorFilters";
import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql";
import { SubprocessorsPage, subprocessorsPageQuery } from "./SubprocessorsPage";
import { SubprocessorsPageSkeleton } from "./SubprocessorsPageSkeleton";
export default function SubprocessorsPageLoader() {
const filters = useSubprocessorFilters();

View File

@@ -12,11 +12,11 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { TextField } from "@probo/ui/src/v2/form/TextField";
import { Select } from "@probo/ui/src/v2/Select/Select";
import { SelectItem } from "@probo/ui/src/v2/Select/SelectItem";
import { SelectPopup } from "@probo/ui/src/v2/Select/SelectPopup";
import { SelectTrigger } from "@probo/ui/src/v2/Select/SelectTrigger";
import { TextField } from "@probo/ui/src/v2/form/TextField";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
@@ -27,21 +27,14 @@ import { useSubprocessorSearch } from "../_lib/useSubprocessorSearch";
import type { SubprocessorsToolbar_query$key } from "./__generated__/SubprocessorsToolbar_query.graphql";
// Unfiltered facet data: the distinct categories and countries actually present,
// used to populate the filter dropdowns (aliased so it does not collide with the
// filtered list selection on the same trust center).
// Facet data: the distinct categories and countries actually present across the
// trust center's published subprocessors, used to populate the filter dropdowns
// (server-computed so the options never dead-end on an empty result).
const subprocessorsToolbarFragment = graphql`
fragment SubprocessorsToolbar_query on Query {
currentTrustCenter @required(action: THROW) {
allSubprocessors: subprocessors(first: 250) {
edges {
node {
id
category
countries
}
}
}
subprocessorCategories
subprocessorCountries
}
}
`;
@@ -59,17 +52,15 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
const { category, country, setCategory, setCountry } = useSubprocessorFilters();
const [queryInput, setQueryInput] = useSubprocessorSearch();
const nodes = data.currentTrustCenter.allSubprocessors.edges.map(edge => edge.node);
const { subprocessorCategories, subprocessorCountries } = data.currentTrustCenter;
const categoryOptions = useMemo(() => {
const present = [...new Set(nodes.map(node => node.category))];
return present.sort((a, b) => t(`categories.${a}.label`).localeCompare(t(`categories.${b}.label`)));
}, [nodes, t]);
return [...subprocessorCategories].sort((a, b) => t(`categories.${a}.label`).localeCompare(t(`categories.${b}.label`)));
}, [subprocessorCategories, t]);
const countryOptions = useMemo(() => {
const present = [...new Set(nodes.flatMap(node => node.countries))];
return present.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b)));
}, [nodes, countryLabel]);
return [...subprocessorCountries].sort((a, b) => countryLabel(a).localeCompare(countryLabel(b)));
}, [subprocessorCountries, countryLabel]);
return (
<div className="flex flex-wrap items-center gap-3">

View File

@@ -780,6 +780,78 @@ WHERE
return count, nil
}
func (v *ThirdParties) LoadDistinctTrustCenterCategoriesByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) ([]ThirdPartyCategory, error) {
q := `
SELECT DISTINCT
category
FROM
third_parties
WHERE
%s
AND organization_id = @organization_id
AND show_on_trust_center = true
ORDER BY
category ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query thirdParty categories: %w", err)
}
categories, err := pgx.CollectRows(rows, pgx.RowTo[ThirdPartyCategory])
if err != nil {
return nil, fmt.Errorf("cannot collect thirdParty categories: %w", err)
}
return categories, nil
}
func (v *ThirdParties) LoadDistinctTrustCenterCountriesByOrganizationID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
) ([]CountryCode, error) {
q := `
SELECT DISTINCT
unnest(countries) AS country
FROM
third_parties
WHERE
%s
AND organization_id = @organization_id
AND show_on_trust_center = true
ORDER BY
country ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query thirdParty countries: %w", err)
}
countries, err := pgx.CollectRows(rows, pgx.RowTo[CountryCode])
if err != nil {
return nil, fmt.Errorf("cannot collect thirdParty countries: %w", err)
}
return countries, nil
}
func (v *ThirdParties) LoadByOrganizationID(
ctx context.Context,
conn pg.Querier,

View File

@@ -33,6 +33,11 @@ type TrustCenter implements Node {
filter: SubprocessorFilter
): SubprocessorConnection! @goField(forceResolver: true)
subprocessorCategories: [SubprocessorCategory!]!
@goField(forceResolver: true)
subprocessorCountries: [CountryCode!]! @goField(forceResolver: true)
references(
first: Int
after: CursorKey

View File

@@ -831,6 +831,34 @@ func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.Trus
return types.NewSubprocessorConnection(thirdPartyPage, r, obj.ID, thirdPartyFilter), nil
}
// SubprocessorCategories is the resolver for the subprocessorCategories field.
func (r *trustCenterResolver) SubprocessorCategories(ctx context.Context, obj *types.TrustCenter) ([]coredata.ThirdPartyCategory, error) {
compliancePage := compliancepage.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
categories, err := r.trust.ThirdParties.ListDistinctTrustCenterCategoriesForOrganizationID(ctx, scope, obj.Organization.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list subprocessor categories", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return categories, nil
}
// SubprocessorCountries is the resolver for the subprocessorCountries field.
func (r *trustCenterResolver) SubprocessorCountries(ctx context.Context, obj *types.TrustCenter) ([]coredata.CountryCode, error) {
compliancePage := compliancepage.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
countries, err := r.trust.ThirdParties.ListDistinctTrustCenterCountriesForOrganizationID(ctx, scope, obj.Organization.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list subprocessor countries", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return countries, nil
}
// References is the resolver for the references field.
func (r *trustCenterResolver) References(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TrustCenterReferenceConnection, error) {
compliancePage := compliancepage.CompliancePageFromContext(ctx)

View File

@@ -80,6 +80,64 @@ func (s ThirdPartyService) ListForOrganizationId(
return page.NewPage(thirdParties, cursor), nil
}
func (s ThirdPartyService) ListDistinctTrustCenterCategoriesForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
) ([]coredata.ThirdPartyCategory, error) {
var categories []coredata.ThirdPartyCategory
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
thirdParties := &coredata.ThirdParties{}
result, err := thirdParties.LoadDistinctTrustCenterCategoriesByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot load thirdParty categories: %w", err)
}
categories = result
return nil
},
)
if err != nil {
return nil, err
}
return categories, nil
}
func (s ThirdPartyService) ListDistinctTrustCenterCountriesForOrganizationID(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
) ([]coredata.CountryCode, error) {
var countries []coredata.CountryCode
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
thirdParties := &coredata.ThirdParties{}
result, err := thirdParties.LoadDistinctTrustCenterCountriesByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot load thirdParty countries: %w", err)
}
countries = result
return nil
},
)
if err != nil {
return nil, err
}
return countries, nil
}
func (s ThirdPartyService) CountForTrustCenterId(
ctx context.Context,
scope coredata.Scoper,