From 52b6ccac550e73efff6029ad07e97b1f7e3fe8ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 8 Jul 2026 10:24:06 -0400 Subject: [PATCH] Populate subprocessor filters from server facets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é --- .../pages/subprocessors/SubprocessorsPage.tsx | 8 +-- .../subprocessors/SubprocessorsPageLoader.tsx | 6 +- .../_components/SubprocessorsToolbar.tsx | 31 +++----- pkg/coredata/third_party.go | 72 +++++++++++++++++++ .../api/trust/v1/graphql/trust_center.graphql | 5 ++ .../api/trust/v1/trust_center_resolvers.go | 28 ++++++++ pkg/trust/third_party_service.go | 58 +++++++++++++++ 7 files changed, 181 insertions(+), 27 deletions(-) diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx index c599e12a9..319916fa2 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx @@ -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) { diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx index fcb9947c3..460c3cdae 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPageLoader.tsx @@ -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(); diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx index 0c8ff9d23..117e72860 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx @@ -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 (
diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index e4c924932..8a333350b 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -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, diff --git a/pkg/server/api/trust/v1/graphql/trust_center.graphql b/pkg/server/api/trust/v1/graphql/trust_center.graphql index 749b5e2de..f3d9b4705 100644 --- a/pkg/server/api/trust/v1/graphql/trust_center.graphql +++ b/pkg/server/api/trust/v1/graphql/trust_center.graphql @@ -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 diff --git a/pkg/server/api/trust/v1/trust_center_resolvers.go b/pkg/server/api/trust/v1/trust_center_resolvers.go index 48a10f1ba..349333071 100644 --- a/pkg/server/api/trust/v1/trust_center_resolvers.go +++ b/pkg/server/api/trust/v1/trust_center_resolvers.go @@ -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) diff --git a/pkg/trust/third_party_service.go b/pkg/trust/third_party_service.go index e0b175fa5..016eeb3b0 100644 --- a/pkg/trust/third_party_service.go +++ b/pkg/trust/third_party_service.go @@ -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,