Filter trust center subprocessors server-side

Subprocessor filtering for the compliance portal happens in the backend
rather than the client. Add a SubprocessorFilter (query, category,
country) to the trust API's subprocessors connection, thread it through
the resolver and service, and extend the coredata ThirdParty filter with
category equality and country array membership. The connection stores the
filter so totalCount reflects the filtered set. Add e2e coverage for the
new filtering.

On the frontend, convert the page to a refetchable fragment whose filter
arguments are driven by URL-persisted, debounced toolbar state (category
and region selects plus a search field), populate the dropdowns from an
unfiltered facet selection, and offer to clear filters from the empty
state.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-30 14:40:50 +02:00
parent f1f4c93104
commit c9b74d6de0
20 changed files with 623 additions and 31 deletions

View File

@@ -12,22 +12,41 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { PreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery } from "react-relay";
import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay";
import { PageHeader } from "#/components/PageHeader/PageHeader";
import { SubprocessorCategorySection } from "./_components/SubprocessorCategorySection";
import type { SubprocessorNode } 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 SubprocessorsPageQuery($query: String, $category: SubprocessorCategory, $country: CountryCode) {
...SubprocessorsPage_query @arguments(query: $query, category: $category, country: $country)
...SubprocessorsToolbar_query
}
`;
const subprocessorsPageFragment = graphql`
fragment SubprocessorsPage_query on Query
@refetchable(queryName: "SubprocessorsPageRefetchQuery")
@argumentDefinitions(
query: { type: "String" }
category: { type: "SubprocessorCategory" }
country: { type: "CountryCode" }
) {
currentTrustCenter @required(action: THROW) {
subprocessors(first: 250) {
subprocessors(first: 250, filter: { query: $query, category: $category, country: $country }) {
totalCount
edges {
node {
@@ -47,15 +66,35 @@ interface SubprocessorsPageProps {
export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) {
const { t } = useTranslation("subprocessors");
const data = usePreloadedQuery<SubprocessorsPageQuery>(subprocessorsPageQuery, queryRef);
const { subprocessors } = data.currentTrustCenter;
const root = usePreloadedQuery<SubprocessorsPageQuery>(subprocessorsPageQuery, queryRef);
const [data, refetch] = useRefetchableFragment<SubprocessorsPageRefetchQuery, SubprocessorsPage_query$key>(
subprocessorsPageFragment,
root,
);
const filters = useSubprocessorFilters();
const { query, category, country } = filters;
// The initial query already loaded with the URL's filter values; only refetch
// on subsequent filter changes.
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
refetch(toQueryVariables({ query, category, country }), { fetchPolicy: "store-or-network" });
}, [refetch, query, category, country]);
const { subprocessors } = data.currentTrustCenter;
const nodes: SubprocessorNode[] = subprocessors.edges.map(edge => edge.node);
const groups = groupByCategory(nodes, category => t(`categories.${category}.label`));
const groups = groupByCategory(nodes, value => t(`categories.${value}.label`));
return (
<>
<PageHeader title={t("title")} count={subprocessors.totalCount} />
<PageHeader title={t("title")} count={subprocessors.totalCount}>
<SubprocessorsToolbar queryKey={root} />
</PageHeader>
<div className="flex w-full flex-col items-center px-8 py-8">
<div className="flex w-full max-w-5xl flex-col gap-8">
{groups.length === 0

View File

@@ -12,18 +12,25 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useQueryLoader } from "react-relay";
import { SubprocessorsPage, subprocessorsPageQuery } from "./SubprocessorsPage";
import { SubprocessorsPageSkeleton } from "./SubprocessorsPageSkeleton";
import { toQueryVariables } from "./_lib/toQueryVariables";
import { useSubprocessorFilters } from "./_lib/useSubprocessorFilters";
import type { SubprocessorsPageQuery } from "./__generated__/SubprocessorsPageQuery.graphql";
export default function SubprocessorsPageLoader() {
const filters = useSubprocessorFilters();
const [queryRef, loadQuery] = useQueryLoader<SubprocessorsPageQuery>(subprocessorsPageQuery);
// Seed the first fetch with the URL's filter values; later changes are handled
// by the page's refetch.
const initialVariables = useRef(toQueryVariables(filters));
useEffect(() => {
loadQuery({});
loadQuery(initialVariables.current);
}, [loadQuery]);
if (!queryRef) {

View File

@@ -12,25 +12,40 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
import { ArrowCounterClockwiseIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useTranslation } from "react-i18next";
// Empty state shown when the trust center lists no subprocessors.
import { useSubprocessorFilters } from "../_lib/useSubprocessorFilters";
// Empty state for the subprocessor list. When filters are active it offers to
// clear them; otherwise it states the trust center lists no subprocessors.
export function SubprocessorsEmpty() {
const { t } = useTranslation("subprocessors");
const { hasActiveFilters, clear } = useSubprocessorFilters();
return (
<div className="flex flex-col items-center gap-5 py-8 text-center">
<MagnifyingGlassIcon className="size-6 text-sand-a8" />
<div className="flex max-w-xs flex-col items-center gap-2">
<Text size={2} weight="medium" color="faint">
{t("empty.title")}
{hasActiveFilters ? t("empty.filteredTitle") : t("empty.title")}
</Text>
<Text size={2} color="faint">
{t("empty.description")}
{hasActiveFilters ? t("empty.filteredDescription") : t("empty.description")}
</Text>
</div>
{hasActiveFilters && (
<Button
variant="soft"
color="neutral"
iconStart={<ArrowCounterClockwiseIcon />}
onClick={clear}
>
{t("empty.clearFilters")}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
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";
import { useCountryLabel } from "../_lib/useCountryLabel";
import { useSubprocessorFilters } from "../_lib/useSubprocessorFilters";
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).
const subprocessorsToolbarFragment = graphql`
fragment SubprocessorsToolbar_query on Query {
currentTrustCenter @required(action: THROW) {
allSubprocessors: subprocessors(first: 250) {
edges {
node {
id
category
countries
}
}
}
}
}
`;
interface SubprocessorsToolbarProps {
queryKey: SubprocessorsToolbar_query$key;
}
// Category / region / search controls. Writes filter state to the URL via the
// filter hook; the page reacts to those changes and refetches server-side.
export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
const { t } = useTranslation("subprocessors");
const data = useFragment(subprocessorsToolbarFragment, queryKey);
const countryLabel = useCountryLabel();
const { queryInput, category, country, setQueryInput, setCategory, setCountry } = useSubprocessorFilters();
const nodes = data.currentTrustCenter.allSubprocessors.edges.map(edge => edge.node);
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]);
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 (
<div className="flex flex-wrap items-center gap-3">
<div className="w-40">
<Select value={category || null} onValueChange={value => setCategory(value ?? "")}>
<SelectTrigger placeholder={t("filters.allCategories")}>
{(value: string | null) => (value ? t(`categories.${value}.label`) : t("filters.allCategories"))}
</SelectTrigger>
<SelectPopup>
<SelectItem value={null}>{t("filters.allCategories")}</SelectItem>
{categoryOptions.map(option => (
<SelectItem key={option} value={option}>{t(`categories.${option}.label`)}</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<div className="w-40">
<Select value={country || null} onValueChange={value => setCountry(value ?? "")}>
<SelectTrigger placeholder={t("filters.allRegions")}>
{(value: string | null) => (value ? countryLabel(value) : t("filters.allRegions"))}
</SelectTrigger>
<SelectPopup>
<SelectItem value={null}>{t("filters.allRegions")}</SelectItem>
{countryOptions.map(option => (
<SelectItem key={option} value={option}>{countryLabel(option)}</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<div className="w-60">
<TextField
value={queryInput}
onValueChange={setQueryInput}
placeholder={t("filters.searchPlaceholder")}
aria-label={t("filters.searchPlaceholder")}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { SubprocessorsPageQuery$variables } from "../__generated__/SubprocessorsPageQuery.graphql";
interface FilterValues {
query: string;
category: string;
country: string;
}
// Maps the string-based URL filter state to the typed GraphQL query variables,
// treating empty strings as "no filter". The category/country strings originate
// from the enum-constrained Select options, so the cast is safe.
export function toQueryVariables(filters: FilterValues): SubprocessorsPageQuery$variables {
return {
query: filters.query || null,
category: (filters.category || null) as SubprocessorsPageQuery$variables["category"],
country: (filters.country || null) as SubprocessorsPageQuery$variables["country"],
};
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router";
const SEARCH_DEBOUNCE_MS = 300;
export interface SubprocessorFilters {
// Debounced search term written to the URL (drives the query variables).
query: string;
category: string;
country: string;
// Immediate search input value (updates on every keystroke).
queryInput: string;
hasActiveFilters: boolean;
setQueryInput: (value: string) => void;
setCategory: (value: string) => void;
setCountry: (value: string) => void;
clear: () => void;
}
// Subprocessor filter state, persisted in the URL so it is shareable and
// survives reloads. The search term is debounced before it is committed to the
// URL (and therefore before it triggers a refetch).
export function useSubprocessorFilters(): SubprocessorFilters {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") ?? "";
const country = searchParams.get("country") ?? "";
const query = searchParams.get("q") ?? "";
const [queryInput, setQueryInput] = useState(query);
useEffect(() => {
if (queryInput === query) {
return;
}
const handle = setTimeout(() => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
if (queryInput) {
next.set("q", queryInput);
} else {
next.delete("q");
}
return next;
}, { replace: true });
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(handle);
}, [queryInput, query, setSearchParams]);
const setParam = useCallback((key: string, value: string) => {
setSearchParams((previous) => {
const next = new URLSearchParams(previous);
if (value) {
next.set(key, value);
} else {
next.delete(key);
}
return next;
}, { replace: true });
}, [setSearchParams]);
const setCategory = useCallback((value: string) => setParam("category", value), [setParam]);
const setCountry = useCallback((value: string) => setParam("country", value), [setParam]);
const clear = useCallback(() => {
setQueryInput("");
setSearchParams({}, { replace: true });
}, [setSearchParams]);
return {
query,
category,
country,
queryInput,
hasActiveFilters: query !== "" || category !== "" || country !== "",
setQueryInput,
setCategory,
setCountry,
clear,
};
}

View File

@@ -1,8 +1,16 @@
{
"title": "Subprocessors",
"filters": {
"allCategories": "All categories",
"allRegions": "All regions",
"searchPlaceholder": "Search..."
},
"empty": {
"title": "No subprocessors listed.",
"description": "This trust center has not published any subprocessors yet."
"description": "This trust center has not published any subprocessors yet.",
"filteredTitle": "No subprocessors match your filters.",
"filteredDescription": "Adjust your search or filters to see available subprocessors.",
"clearFilters": "Clear filters"
},
"regions": {
"global": "Global",

View File

@@ -1,8 +1,16 @@
{
"title": "Sous-traitants",
"filters": {
"allCategories": "Toutes les catégories",
"allRegions": "Toutes les régions",
"searchPlaceholder": "Rechercher..."
},
"empty": {
"title": "Aucun sous-traitant répertorié.",
"description": "Ce centre de confiance n'a pas encore publié de sous-traitants."
"description": "Ce centre de confiance n'a pas encore publié de sous-traitants.",
"filteredTitle": "Aucun sous-traitant ne correspond à vos filtres.",
"filteredDescription": "Ajustez votre recherche ou vos filtres pour voir les sous-traitants disponibles.",
"clearFilters": "Réinitialiser les filtres"
},
"regions": {
"global": "International",

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package trust_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestTrustCenter_SubprocessorsFilter(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
trustCenterID := activateTrustCenter(t, owner)
awsName := factory.SafeName("AWS")
awsID := factory.NewThirdParty(owner).WithName(awsName).WithCategory("CLOUD_PROVIDER").Create()
publishSubprocessor(t, owner, awsID, []string{"US"})
stripeName := factory.SafeName("Stripe")
stripeID := factory.NewThirdParty(owner).WithName(stripeName).WithCategory("FINANCE").Create()
publishSubprocessor(t, owner, stripeID, []string{"US", "IE"})
slackName := factory.SafeName("Slack")
slackID := factory.NewThirdParty(owner).WithName(slackName).WithCategory("COLLABORATION").Create()
publishSubprocessor(t, owner, slackID, []string{"US"})
t.Run("no filter returns every published subprocessor", func(t *testing.T) {
t.Parallel()
result := querySubprocessors(t, owner, trustCenterID, nil)
assert.Equal(t, 3, result.CurrentTrustCenter.Subprocessors.TotalCount)
assert.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 3)
})
t.Run("category filter narrows to one category", func(t *testing.T) {
t.Parallel()
result := querySubprocessors(t, owner, trustCenterID, map[string]any{
"category": "CLOUD_PROVIDER",
})
require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount)
require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1)
assert.Equal(t, awsName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name)
})
t.Run("country filter matches array membership", func(t *testing.T) {
t.Parallel()
result := querySubprocessors(t, owner, trustCenterID, map[string]any{
"country": "IE",
})
require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount)
require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1)
assert.Equal(t, stripeName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name)
})
t.Run("query filter matches name substring", func(t *testing.T) {
t.Parallel()
result := querySubprocessors(t, owner, trustCenterID, map[string]any{
"query": slackName,
})
require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount)
require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1)
assert.Equal(t, slackName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name)
})
t.Run("combined filters intersect", func(t *testing.T) {
t.Parallel()
result := querySubprocessors(t, owner, trustCenterID, map[string]any{
"category": "FINANCE",
"country": "US",
})
require.Equal(t, 1, result.CurrentTrustCenter.Subprocessors.TotalCount)
require.Len(t, result.CurrentTrustCenter.Subprocessors.Edges, 1)
assert.Equal(t, stripeName, result.CurrentTrustCenter.Subprocessors.Edges[0].Node.Name)
})
t.Run("non-matching filter returns empty set", func(t *testing.T) {
t.Parallel()
result := querySubprocessors(t, owner, trustCenterID, map[string]any{
"category": "SECURITY",
})
assert.Equal(t, 0, result.CurrentTrustCenter.Subprocessors.TotalCount)
assert.Empty(t, result.CurrentTrustCenter.Subprocessors.Edges)
})
}
type subprocessorsResult struct {
CurrentTrustCenter struct {
Subprocessors struct {
TotalCount int `json:"totalCount"`
Edges []struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Countries []string `json:"countries"`
} `json:"node"`
} `json:"edges"`
} `json:"subprocessors"`
} `json:"currentTrustCenter"`
}
func querySubprocessors(
t *testing.T,
owner *testutil.Client,
trustCenterID string,
filter map[string]any,
) subprocessorsResult {
t.Helper()
const query = `
query($filter: SubprocessorFilter) {
currentTrustCenter {
subprocessors(first: 50, filter: $filter) {
totalCount
edges {
node {
id
name
category
countries
}
}
}
}
}
`
var result subprocessorsResult
err := owner.ExecuteTrust(trustCenterID, query, map[string]any{"filter": filter}, &result)
require.NoError(t, err)
return result
}
func activateTrustCenter(t *testing.T, owner *testutil.Client) string {
t.Helper()
const trustCenterQuery = `
query($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
trustCenter { id }
}
}
}
`
var lookup struct {
Node struct {
TrustCenter struct {
ID string `json:"id"`
} `json:"trustCenter"`
} `json:"node"`
}
err := owner.Execute(trustCenterQuery, map[string]any{
"organizationId": owner.GetOrganizationID().String(),
}, &lookup)
require.NoError(t, err)
require.NotEmpty(t, lookup.Node.TrustCenter.ID)
const activateMutation = `
mutation($input: UpdateTrustCenterInput!) {
updateTrustCenter(input: $input) {
trustCenter { id active }
}
}
`
err = owner.Execute(activateMutation, map[string]any{
"input": map[string]any{
"trustCenterId": lookup.Node.TrustCenter.ID,
"active": true,
},
}, nil)
require.NoError(t, err)
return lookup.Node.TrustCenter.ID
}
func publishSubprocessor(t *testing.T, owner *testutil.Client, thirdPartyID string, countries []string) {
t.Helper()
const mutation = `
mutation($input: UpdateThirdPartyInput!) {
updateThirdParty(input: $input) {
thirdParty { id showOnTrustCenter countries }
}
}
`
err := owner.Execute(mutation, map[string]any{
"input": map[string]any{
"id": thirdPartyID,
"showOnTrustCenter": true,
"countries": countries,
},
}, nil)
require.NoError(t, err)
}

View File

@@ -1433,7 +1433,7 @@ func (h *trackerMappingHandler) prepareOrgThirdParty(
},
func(ctx context.Context, cursor *page.Cursor[coredata.ThirdPartyOrderField]) ([]*coredata.ThirdParty, error) {
var batch coredata.ThirdParties
if err := batch.LoadByOrganizationID(ctx, conn, scope, tp.OrganizationID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil)); err != nil {
if err := batch.LoadByOrganizationID(ctx, conn, scope, tp.OrganizationID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil, nil, nil)); err != nil {
return nil, fmt.Errorf("cannot load org third parties: %w", err)
}

View File

@@ -23,14 +23,24 @@ type (
showOnTrustCenter *bool
level *int
query *string
category *ThirdPartyCategory
country *CountryCode
}
)
func NewThirdPartyFilter(showOnTrustCenter *bool, level *int, query *string) *ThirdPartyFilter {
func NewThirdPartyFilter(
showOnTrustCenter *bool,
level *int,
query *string,
category *ThirdPartyCategory,
country *CountryCode,
) *ThirdPartyFilter {
return &ThirdPartyFilter{
showOnTrustCenter: showOnTrustCenter,
level: level,
query: query,
category: category,
country: country,
}
}
@@ -39,6 +49,8 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
"show_on_trust_center": nil,
"filter_query": nil,
"level": nil,
"filter_category": nil,
"filter_country": nil,
}
if f.showOnTrustCenter != nil {
@@ -53,6 +65,14 @@ func (f *ThirdPartyFilter) SQLArguments() pgx.StrictNamedArgs {
args["level"] = *f.level
}
if f.category != nil {
args["filter_category"] = string(*f.category)
}
if f.country != nil {
args["filter_country"] = string(*f.country)
}
return args
}
@@ -74,5 +94,15 @@ func (f *ThirdPartyFilter) SQLFragment() string {
name ILIKE '%' || @filter_query || '%'
ELSE TRUE
END
AND CASE
WHEN @filter_category::text IS NOT NULL THEN
category = @filter_category::third_party_category
ELSE TRUE
END
AND CASE
WHEN @filter_country::text IS NOT NULL THEN
@filter_country::country_code = ANY(countries)
ELSE TRUE
END
)`
}

View File

@@ -2508,7 +2508,7 @@ func (s *GeneratedDocumentService) buildThirdPartyListDocumentData(
},
func(ctx context.Context, cursor *page.Cursor[coredata.ThirdPartyOrderField]) ([]*coredata.ThirdParty, error) {
var batch coredata.ThirdParties
if err := batch.LoadByOrganizationID(ctx, conn, scope, organization.ID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil)); err != nil {
if err := batch.LoadByOrganizationID(ctx, conn, scope, organization.ID, cursor, coredata.NewThirdPartyFilter(nil, &firstLevel, nil, nil, nil)); err != nil {
return nil, fmt.Errorf("cannot load thirdParties: %w", err)
}

View File

@@ -174,7 +174,7 @@ func (s ThirdPartyService) CountForOrganizationID(
var count int
if filter == nil {
filter = coredata.NewThirdPartyFilter(nil, nil, nil)
filter = coredata.NewThirdPartyFilter(nil, nil, nil, nil, nil)
}
err := s.svc.pg.WithConn(

View File

@@ -1284,7 +1284,7 @@ func (r *organizationResolver) ThirdParties(ctx context.Context, obj *types.Orga
query = filter.Query
}
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, level, query)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, level, query, nil, nil)
page, err := r.probo.ThirdParties.ListForOrganizationID(ctx, scope, obj.ID, cursor, thirdPartyFilter)
if err != nil {

View File

@@ -75,7 +75,7 @@ func (r *Resolver) ListThirdPartiesTool(ctx context.Context, req *mcp.CallToolRe
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.Level, nil)
thirdPartyFilter := coredata.NewThirdPartyFilter(nil, input.Level, nil, nil, nil)
page, err := prb.ThirdParties.ListForOrganizationID(ctx, scope, input.OrganizationID, cursor, thirdPartyFilter)
if err != nil {

View File

@@ -30,6 +30,7 @@ type TrustCenter implements Node {
after: CursorKey
last: Int
before: CursorKey
filter: SubprocessorFilter
): SubprocessorConnection! @goField(forceResolver: true)
references(
@@ -245,6 +246,12 @@ type Subprocessor implements Node @nda {
countries: [CountryCode!]!
}
input SubprocessorFilter {
query: String
category: SubprocessorCategory
country: CountryCode
}
type SubprocessorConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.SubprocessorConnection"

View File

@@ -670,7 +670,7 @@ func (r *subprocessorConnectionResolver) TotalCount(ctx context.Context, obj *ty
switch obj.Resolver.(type) {
case *trustCenterResolver:
count, err := trustService.ThirdParties.CountForTrustCenterId(ctx, scope, obj.ParentID)
count, err := trustService.ThirdParties.CountForTrustCenterId(ctx, scope, obj.ParentID, obj.Filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count subprocessors", log.Error(err))
return 0, gqlutils.Internal(ctx)
@@ -798,7 +798,7 @@ func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter
}
// Subprocessors is the resolver for the subprocessors field.
func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.SubprocessorConnection, error) {
func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.SubprocessorFilter) (*types.SubprocessorConnection, error) {
compliancePage := compliancepage.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
trustService := r.trust
@@ -808,13 +808,27 @@ func (r *trustCenterResolver) Subprocessors(ctx context.Context, obj *types.Trus
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
thirdPartyPage, err := trustService.ThirdParties.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor)
var (
query *string
category *coredata.ThirdPartyCategory
country *coredata.CountryCode
)
if filter != nil {
query = filter.Query
category = filter.Category
country = filter.Country
}
showOnTrustCenter := true
thirdPartyFilter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, query, category, country)
thirdPartyPage, err := trustService.ThirdParties.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, thirdPartyFilter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list subprocessors", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewSubprocessorConnection(thirdPartyPage, r, obj.ID), nil
return types.NewSubprocessorConnection(thirdPartyPage, r, obj.ID, thirdPartyFilter), nil
}
// References is the resolver for the references field.

View File

@@ -28,6 +28,7 @@ type (
Resolver any
ParentID gid.GID
Filter *coredata.ThirdPartyFilter
}
)
@@ -35,6 +36,7 @@ func NewSubprocessorConnection(
p *page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField],
parentType any,
parentID gid.GID,
filter *coredata.ThirdPartyFilter,
) *SubprocessorConnection {
edges := make([]*SubprocessorEdge, len(p.Data))
for i, thirdParty := range p.Data {
@@ -47,6 +49,7 @@ func NewSubprocessorConnection(
Resolver: parentType,
ParentID: parentID,
Filter: filter,
}
}

View File

@@ -553,7 +553,10 @@ func (s *Service) fetchThirdParties(ctx context.Context, scope coredata.Scoper,
},
)
result, err := s.ThirdParties.ListForOrganizationId(ctx, scope, orgID, cursor)
showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil, nil, nil)
result, err := s.ThirdParties.ListForOrganizationId(ctx, scope, orgID, cursor, filter)
if err != nil {
return nil, fmt.Errorf("cannot list thirdParties: %w", err)
}

View File

@@ -58,15 +58,13 @@ func (s ThirdPartyService) ListForOrganizationId(
scope coredata.Scoper,
organizationID gid.GID,
cursor *page.Cursor[coredata.ThirdPartyOrderField],
filter *coredata.ThirdPartyFilter,
) (*page.Page[*coredata.ThirdParty, coredata.ThirdPartyOrderField], error) {
var thirdParties coredata.ThirdParties
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil)
err := thirdParties.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load thirdParties: %w", err)
@@ -86,6 +84,7 @@ func (s ThirdPartyService) CountForTrustCenterId(
ctx context.Context,
scope coredata.Scoper,
trustCenterID gid.GID,
filter *coredata.ThirdPartyFilter,
) (int, error) {
var count int
@@ -98,8 +97,6 @@ func (s ThirdPartyService) CountForTrustCenterId(
}
thirdParties := &coredata.ThirdParties{}
showOnTrustCenter := true
filter := coredata.NewThirdPartyFilter(&showOnTrustCenter, nil, nil)
count, err = thirdParties.CountByOrganizationID(ctx, conn, scope, trustCenter.OrganizationID, filter)
if err != nil {