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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
222
e2e/trust/subprocessors_filter_test.go
Normal file
222
e2e/trust/subprocessors_filter_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user