From 91d4dae941a40f8baf58abaeac6518ae87939a75 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 8 Jun 2026 15:40:20 +0200 Subject: [PATCH 01/23] Refactor freeform search components and queries to utilize new ScreeningFreeformSearchDto structure --- .../FreeformSearch/ViewSavedResults.tsx | 296 +++++++++--------- packages/app-builder/src/models/screening.ts | 27 +- .../src/queries/screening/freeform-search.ts | 62 ++-- .../src/repositories/ScreeningRepository.ts | 50 +-- .../_app/_builder/screening-search/index.tsx | 4 +- .../app-builder/src/server-fns/screenings.ts | 43 --- .../openapis/marblecore-api/_schemas.yml | 4 + .../src/generated/marblecore-api.ts | 55 ++++ 8 files changed, 251 insertions(+), 290 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index 325269a14..85bcca53a 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -2,26 +2,26 @@ import { DateRangeFilter } from '@app-builder/components/Filters'; import { PanelContainer, PanelContent, PanelFooter, PanelRoot } from '@app-builder/components/Panel/Panel'; import { type SavedScreeningSearch } from '@app-builder/models/screening'; import { useSavedFreeformSearchesQuery } from '@app-builder/queries/screening/freeform-search'; -import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail'; +// import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail'; import { useOrganizationUsers } from '@app-builder/services/organization/organization-users'; import { formatDateTimeWithoutPresets, formatDuration, useFormatLanguage } from '@app-builder/utils/format'; -import { useDebouncedCallbackRef } from '@marble/shared'; -import { type ReactNode, useMemo, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Temporal } from 'temporal-polyfill'; +// import { Temporal } from 'temporal-polyfill'; import { - Avatar, + // Avatar, Button, - Collapsible, + // Collapsible, cn, - ExpandableGroupTagLine, + // ExpandableGroupTagLine, Input, MenuCommand, Separator, Tag, } from 'ui-design-system'; import { Icon } from 'ui-icons'; -import { FreeformMatchCard } from './FreeformMatchCard'; + +// import { FreeformMatchCard } from './FreeformMatchCard'; interface StaticDateRangeFilter { type: 'static'; @@ -34,17 +34,17 @@ interface DynamicDateRangeFilter { } type DateRangeFilterValue = StaticDateRangeFilter | DynamicDateRangeFilter | null; -function toIsoRange(value: DateRangeFilterValue): { fromDate?: string; toDate?: string } { - if (!value) return {}; - if (value.type === 'static') { - return { fromDate: value.startDate || undefined, toDate: value.endDate || undefined }; - } - const now = Temporal.Now.zonedDateTimeISO(); - return { - fromDate: now.add(value.fromNow).toInstant().toString(), - toDate: now.toInstant().toString(), - }; -} +// function toIsoRange(value: DateRangeFilterValue): { fromDate?: string; toDate?: string } { +// if (!value) return {}; +// if (value.type === 'static') { +// return { fromDate: value.startDate || undefined, toDate: value.endDate || undefined }; +// } +// const now = Temporal.Now.zonedDateTimeISO(); +// return { +// fromDate: now.add(value.fromNow).toInstant().toString(), +// toDate: now.toInstant().toString(), +// }; +// } const PAGE_SIZES = [25, 50, 100] as const; type PageSize = (typeof PAGE_SIZES)[number]; @@ -54,36 +54,37 @@ export const ViewSavedResults = () => { const [open, setOpen] = useState(false); const [nameInput, setNameInput] = useState(''); - const [name, setName] = useState(''); + // const [name, setName] = useState(''); const [dateRange, setDateRange] = useState(null); const [ownerId, setOwnerId] = useState(undefined); const [page, setPage] = useState(1); const [limit, setLimit] = useState(25); - const applyName = useDebouncedCallbackRef((value: string) => { - setName(value); - setPage(1); - }, 300); + // const applyName = useDebouncedCallbackRef((value: string) => { + // setName(value); + // setPage(1); + // }, 300); - const { fromDate, toDate } = useMemo(() => toIsoRange(dateRange), [dateRange]); + // const { fromDate, toDate } = useMemo(() => toIsoRange(dateRange), [dateRange]); - const query = useSavedFreeformSearchesQuery({ - name: name || undefined, - fromDate, - toDate, - ownerId, - page, - limit, - }); + const query = useSavedFreeformSearchesQuery(); + + // { + // name: name || undefined, + // fromDate, + // toDate, + // ownerId, + // page, + // limit, + // }); const data = query.data?.success ? query.data.data : undefined; - const items = data?.items ?? []; - const total = data?.total ?? 0; + const items = data?.data ?? []; + const hasNext = data?.has_next_page ?? false; const rangeStart = items.length > 0 ? (page - 1) * limit + 1 : 0; const rangeEnd = (page - 1) * limit + items.length; const hasPrev = page > 1; - const hasNext = page * limit < total; return ( <> @@ -114,7 +115,7 @@ export const ViewSavedResults = () => { value={nameInput} onChange={(e) => { setNameInput(e.currentTarget.value); - applyName(e.currentTarget.value); + // applyName(e.currentTarget.value); }} /> { }; function SavedSearchRow({ search }: { search: SavedScreeningSearch }) { - const { t } = useTranslation(['screenings', 'common']); - const language = useFormatLanguage(); - const { currentUser } = useOrganizationDetails(); - const { getOrgUserById } = useOrganizationUsers(); - const owner = getOrgUserById(search.ownerId); - const isYou = currentUser.actorIdentity.userId === search.ownerId; - - return ( - - -
- {search.name} - {owner ? ( - - - - {`${owner.firstName} ${owner.lastName}`.trim()} - {isYou ? ` (${t('screenings:freeform_search.saved_results.you')})` : null} - - - ) : ( - {search.ownerId} - )} - -
-
- - {formatDateTimeWithoutPresets(search.createdAt, { language, dateStyle: 'short' })} - - - - {t('screenings:freeform_search.results_count', { count: search.results.length })} - -
-
- -
- {search.results.map((entity) => ( - - ))} -
-
-
- ); -} - -const inputTagOverflowButtonClassName = 'cursor-pointer shrink-0'; - -function InputTags({ input }: { input: SavedScreeningSearch['inputs'] }) { - const { t } = useTranslation(['screenings']); - - const tagItems = useMemo(() => { - const items: ReactNode[] = []; - - if (input.entityType) { - items.push( - , - ); - } - - const datasets = input.datasets.filter((d) => d.indexOf(':') <= 0); - if (datasets.length > 0) { - items.push(); - } - - for (const [field, value] of Object.entries(input.fields)) { - if (value) { - items.push( - , - ); - } - } - - return items; - }, [input, t]); - - if (tagItems.length === 0) return null; + // const { t } = useTranslation(['screenings', 'common']); + // const language = useFormatLanguage(); + // const { currentUser } = useOrganizationDetails(); + // const { getOrgUserById } = useOrganizationUsers(); + // const owner = getOrgUserById(search.ownerId); + // const isYou = currentUser.actorIdentity.userId === search.ownerId; return ( - ( - - +{overflow} - - )} - lessButton={(onCollapse) => ( - - - - )} - /> +
{search.id}
+ // + // + //
+ // {search.name} + // {owner ? ( + // + // + // + // {`${owner.firstName} ${owner.lastName}`.trim()} + // {isYou ? ` (${t('screenings:freeform_search.saved_results.you')})` : null} + // + // + // ) : ( + // {search.ownerId} + // )} + // + //
+ //
+ // + // {formatDateTimeWithoutPresets(search.createdAt, { language, dateStyle: 'short' })} + // + // + // + // {t('screenings:freeform_search.results_count', { count: search.results.length })} + // + //
+ //
+ // + //
+ // {search.results.map((entity) => ( + // + // ))} + //
+ //
+ //
); } -function InputTag({ label, values }: { label?: string; values: string | string[] }) { - if (values.length === 0) return null; - return ( - - {label ? {label} : null} - {Array.isArray(values) ? ( - - {values.slice(0, 2).join(', ')} - {values.length > 2 ? {` +${values.length - 2}`} : null} - - ) : ( - {values} - )} - - ); -} +// const inputTagOverflowButtonClassName = 'cursor-pointer shrink-0'; + +// function InputTags({ input }: { input: SavedScreeningSearch['inputs'] }) { +// const { t } = useTranslation(['screenings']); + +// const tagItems = useMemo(() => { +// const items: ReactNode[] = []; + +// if (input.entityType) { +// items.push( +// , +// ); +// } + +// const datasets = input.datasets.filter((d) => d.indexOf(':') <= 0); +// if (datasets.length > 0) { +// items.push(); +// } + +// for (const [field, value] of Object.entries(input.fields)) { +// if (value) { +// items.push( +// , +// ); +// } +// } + +// return items; +// }, [input, t]); + +// if (tagItems.length === 0) return null; + +// return ( +// ( +// +// +{overflow} +// +// )} +// lessButton={(onCollapse) => ( +// +// +// +// )} +// /> +// ); +// } + +// function InputTag({ label, values }: { label?: string; values: string | string[] }) { +// if (values.length === 0) return null; +// return ( +// +// {label ? {label} : null} +// {Array.isArray(values) ? ( +// +// {values.slice(0, 2).join(', ')} +// {values.length > 2 ? {` +${values.length - 2}`} : null} +// +// ) : ( +// {values} +// )} +// +// ); +// } function PeriodFilter({ value, diff --git a/packages/app-builder/src/models/screening.ts b/packages/app-builder/src/models/screening.ts index db54a83d0..0f26ca14b 100644 --- a/packages/app-builder/src/models/screening.ts +++ b/packages/app-builder/src/models/screening.ts @@ -6,6 +6,7 @@ import { type ScreeningEntityDto, type ScreeningErrorDto, type ScreeningFileDto, + ScreeningFreeformSearchDto, type ScreeningMatchDto, type ScreeningMatchPayloadDto, type ScreeningRequestDto, @@ -716,27 +717,19 @@ export interface SavedScreeningSearchInputs { limit?: number; } -export interface SavedScreeningSearch { - id: string; - name: string; - ownerId: string; - createdAt: string; - inputs: SavedScreeningSearchInputs; - results: ScreeningMatchPayload[]; -} +export type SavedScreeningSearch = ScreeningFreeformSearchDto; export interface SavedScreeningSearchFilters { - fromDate?: string; - toDate?: string; - name?: string; - ownerId?: string; limit?: number; - page?: number; + offsetId?: string; + order?: 'ASC' | 'DESC'; + sorting?: 'created_at'; + userId?: string; + apiKeyId?: string; + isSaved?: boolean; } export interface SavedScreeningSearchPage { - items: SavedScreeningSearch[]; - total: number; - page: number; - limit: number; + data: ScreeningFreeformSearchDto[]; + has_next_page: boolean; } diff --git a/packages/app-builder/src/queries/screening/freeform-search.ts b/packages/app-builder/src/queries/screening/freeform-search.ts index 3dc54b0be..4f9b46f70 100644 --- a/packages/app-builder/src/queries/screening/freeform-search.ts +++ b/packages/app-builder/src/queries/screening/freeform-search.ts @@ -1,18 +1,14 @@ import { - type SavedScreeningSearch, type SavedScreeningSearchFilters, type SavedScreeningSearchPage, type ScreeningMatchPayload, } from '@app-builder/models/screening'; import { - deleteSavedFreeformSearchFn, type FreeformSearchInput, freeformSearchFn, listSavedFreeformSearchesFn, - type SaveFreeformSearchInput, - saveFreeformSearchFn, } from '@app-builder/server-fns/screenings'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useServerFn } from '@tanstack/react-start'; type FreeformSearchResponse = { success: true; data: ScreeningMatchPayload[] } | { success: false; error: unknown }; @@ -36,22 +32,22 @@ export const useFreeformSearchMutation = () => { }); }; -type SaveFreeformSearchResponse = { success: true; data: SavedScreeningSearch } | { success: false; error: unknown }; +// type SaveFreeformSearchResponse = { success: true; data: SavedScreeningSearch } | { success: false; error: unknown }; -export const useSaveFreeformSearchMutation = () => { - const saveSearch = useServerFn(saveFreeformSearchFn); - const queryClient = useQueryClient(); +// export const useSaveFreeformSearchMutation = () => { +// const saveSearch = useServerFn(saveFreeformSearchFn); +// const queryClient = useQueryClient(); - return useMutation({ - mutationKey: ['screening', 'save-freeform-search'], - mutationFn: async (input: SaveFreeformSearchInput): Promise => { - return saveSearch({ data: input }) as Promise; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); - }, - }); -}; +// return useMutation({ +// mutationKey: ['screening', 'save-freeform-search'], +// mutationFn: async (input: SaveFreeformSearchInput): Promise => { +// return saveSearch({ data: input }) as Promise; +// }, +// onSuccess: () => { +// queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); +// }, +// }); +// }; type ListSavedFreeformSearchesResponse = | { success: true; data: SavedScreeningSearchPage } @@ -68,19 +64,19 @@ export const useSavedFreeformSearchesQuery = (filters: SavedScreeningSearchFilte }); }; -type DeleteSavedFreeformSearchResponse = { success: true } | { success: false; error: unknown }; +// type DeleteSavedFreeformSearchResponse = { success: true } | { success: false; error: unknown }; -export const useDeleteSavedFreeformSearchMutation = () => { - const deleteSearch = useServerFn(deleteSavedFreeformSearchFn); - const queryClient = useQueryClient(); +// export const useDeleteSavedFreeformSearchMutation = () => { +// const deleteSearch = useServerFn(deleteSavedFreeformSearchFn); +// const queryClient = useQueryClient(); - return useMutation({ - mutationKey: ['screening', 'delete-saved-search'], - mutationFn: async (id: string): Promise => { - return deleteSearch({ data: { id } }) as Promise; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); - }, - }); -}; +// return useMutation({ +// mutationKey: ['screening', 'delete-saved-search'], +// mutationFn: async (id: string): Promise => { +// return deleteSearch({ data: { id } }) as Promise; +// }, +// onSuccess: () => { +// queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); +// }, +// }); +// }; diff --git a/packages/app-builder/src/repositories/ScreeningRepository.ts b/packages/app-builder/src/repositories/ScreeningRepository.ts index eee093130..86b9081b5 100644 --- a/packages/app-builder/src/repositories/ScreeningRepository.ts +++ b/packages/app-builder/src/repositories/ScreeningRepository.ts @@ -7,10 +7,8 @@ import { adaptScreeningMatch, adaptScreeningMatchPayload, type OpenSanctionEntitySchema, - type SavedScreeningSearch, type SavedScreeningSearchFilters, - type SavedScreeningSearchInputs, - type SavedScreeningSearchPage, + SavedScreeningSearchPage, type Screening, ScreeningAvailableFiltersAdapted, type ScreeningFile, @@ -58,19 +56,9 @@ export interface ScreeningRepository { getAiSuggestions(args: { screeningId: string }): Promise; enrichedData(args: { entityId: string }): Promise; getAvailableFilters(args: { feature: AvailableFeatures }): Promise; - saveScreeningSearch(args: { - name: string; - inputs: SavedScreeningSearchInputs; - results: ScreeningMatchPayload[]; - }): Promise; listSavedScreeningSearches(filters: SavedScreeningSearchFilters): Promise; - deleteSavedScreeningSearch(args: { id: string }): Promise; } -// In-memory mock store for saved freeform searches. Replace once a marblecore-api endpoint exists. -const savedScreeningSearches = new Map(); -const MOCK_SAVED_SEARCH_OWNER_ID = 'mock-owner'; - export function makeGetScreeningRepository() { return (marbleCoreApiClient: MarbleCoreApi): ScreeningRepository => ({ getAvailableFilters: async ({ feature }) => { @@ -170,42 +158,8 @@ export function makeGetScreeningRepository() { getAiSuggestions: async ({ screeningId }) => { return R.map(await marbleCoreApiClient.getScreeningAiSuggestions(screeningId), adaptScreeningAiSuggestion); }, - saveScreeningSearch: async ({ name, inputs, results }) => { - const record: SavedScreeningSearch = { - id: crypto.randomUUID(), - name, - ownerId: MOCK_SAVED_SEARCH_OWNER_ID, - createdAt: new Date().toISOString(), - inputs, - results, - }; - savedScreeningSearches.set(record.id, record); - return record; - }, listSavedScreeningSearches: async (filters) => { - const all = Array.from(savedScreeningSearches.values()); - - const filtered = R.pipe( - all, - R.filter((s) => { - if (filters.name && !s.name.toLowerCase().includes(filters.name.toLowerCase())) return false; - if (filters.ownerId && s.ownerId !== filters.ownerId) return false; - if (filters.fromDate && s.createdAt < filters.fromDate) return false; - if (filters.toDate && s.createdAt > filters.toDate) return false; - return true; - }), - R.sortBy([(s) => s.createdAt, 'desc']), - ); - - const limit = filters.limit ?? 20; - const page = filters.page ?? 1; - const start = (page - 1) * limit; - const items = filtered.slice(start, start + limit); - - return { items, total: filtered.length, page, limit }; - }, - deleteSavedScreeningSearch: async ({ id }) => { - savedScreeningSearches.delete(id); + return await marbleCoreApiClient.listFreeformSearches(filters); }, }); } diff --git a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx index ef1d4eb0e..3282836f8 100644 --- a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx +++ b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx @@ -10,7 +10,7 @@ import { } from '@app-builder/components/Screenings/FreeformSearch/FreeformSearchPrint'; // TODO: Uncomment when the save search and view saved results are implemented // import { SaveSearch } from '@app-builder/components/Screenings/FreeformSearch/SaveSearch'; -// import { ViewSavedResults } from '@app-builder/components/Screenings/FreeformSearch/ViewSavedResults'; +import { ViewSavedResults } from '@app-builder/components/Screenings/FreeformSearch/ViewSavedResults'; import { authMiddleware } from '@app-builder/middlewares/auth-middleware'; import { normalizeListConfig } from '@app-builder/queries/screening/lists-config'; import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail'; @@ -81,7 +81,7 @@ function ScreeningSearchIndexPage() { {/* */} )} - {/* */} + diff --git a/packages/app-builder/src/server-fns/screenings.ts b/packages/app-builder/src/server-fns/screenings.ts index a31c1e59d..5e3c741dd 100644 --- a/packages/app-builder/src/server-fns/screenings.ts +++ b/packages/app-builder/src/server-fns/screenings.ts @@ -1,9 +1,7 @@ -import { SEARCH_ENTITIES, SearchableSchema } from '@app-builder/constants/screening-entity'; import { authMiddleware } from '@app-builder/middlewares/auth-middleware'; import { isStatusConflictHttpError } from '@app-builder/models/http-errors'; import { availableFeatures, - type SavedScreeningSearch, type SavedScreeningSearchPage, type Screening, type ScreeningMatchPayload, @@ -109,19 +107,6 @@ export const getEnrichedDataInputSchema = z.object({ }); export type GetEnrichedDataInput = z.infer; -export const saveFreeformSearchSchema = z.object({ - name: z.string().min(1).max(120), - inputs: z.object({ - entityType: z.enum(Object.keys(SEARCH_ENTITIES) as [SearchableSchema]), - fields: z.record(z.string(), z.string()), - datasets: z.array(z.string()), - threshold: z.number().min(0).max(100).optional(), - limit: z.number().min(10).max(50).optional(), - }), - results: z.array(z.any()), -}); -export type SaveFreeformSearchInput = z.infer; - export const savedSearchFiltersSchema = z.object({ fromDate: z.string().optional(), toDate: z.string().optional(), @@ -214,22 +199,6 @@ export const freeformSearchFn = createServerFn({ method: 'POST' }) } }); -export const saveFreeformSearchFn = createServerFn({ method: 'POST' }) - .middleware([authMiddleware]) - .inputValidator(saveFreeformSearchSchema) - .handler(async ({ context, data }) => { - try { - const saved = await context.authInfo.screening.saveScreeningSearch({ - name: data.name, - inputs: data.inputs, - results: data.results as ScreeningMatchPayload[], - }); - return { success: true as const, data: saved as SavedScreeningSearch }; - } catch { - return { success: false as const, error: 'Save freeform search failed' }; - } - }); - export const listSavedFreeformSearchesFn = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .inputValidator(savedSearchFiltersSchema) @@ -242,18 +211,6 @@ export const listSavedFreeformSearchesFn = createServerFn({ method: 'GET' }) } }); -export const deleteSavedFreeformSearchFn = createServerFn({ method: 'POST' }) - .middleware([authMiddleware]) - .inputValidator(z.object({ id: z.string() })) - .handler(async ({ context, data }) => { - try { - await context.authInfo.screening.deleteSavedScreeningSearch({ id: data.id }); - return { success: true as const }; - } catch { - return { success: false as const, error: 'Delete saved search failed' }; - } - }); - export const getEnrichedDataFn = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .inputValidator(getEnrichedDataInputSchema) diff --git a/packages/marble-api/openapis/marblecore-api/_schemas.yml b/packages/marble-api/openapis/marblecore-api/_schemas.yml index 7b4d8604f..b8a47d054 100644 --- a/packages/marble-api/openapis/marblecore-api/_schemas.yml +++ b/packages/marble-api/openapis/marblecore-api/_schemas.yml @@ -323,6 +323,10 @@ updateScreeningMatchDto: $ref: screenings.yml#/components/schemas/updateScreeningMatchDto ScreeningAiSuggestionDto: $ref: screenings.yml#/components/schemas/ScreeningAiSuggestionDto +ScreeningFreeformSearchDto: + $ref: screenings.yml#/components/schemas/ScreeningFreeformSearchDto +ScreeningFreeformSearchConfigDto: + $ref: screenings.yml#/components/schemas/ScreeningFreeformSearchConfigDto # SCENARIO PUBLICATIONS diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index 76e40d9dc..79725def8 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -1146,6 +1146,25 @@ export type UpdateScreeningMatchDto = { whitelist?: boolean; }; export type ScreeningRefineDto = object; +export type ScreeningFreeformSearchConfigDto = { + provider: string; + filters: ScreeningConfigBodyFiltersDto; + threshold?: number | null; + limit: number; +}; +export type ScreeningFreeformSearchDto = { + id: string; + user_id?: string; + api_key_id?: string; + created_at: string; + search_input: { + "type": "Thing" | "Person" | "Organization" | "Vehicle"; + query: { + [key: string]: string[]; + }; + }; + search_config: ScreeningFreeformSearchConfigDto; +}; export type OpenSanctionsUpstreamDatasetFreshnessDto = { version: string; name: string; @@ -4158,6 +4177,42 @@ export function freeformSearch(body?: { body }))); } +/** + * List past freeform searches + */ +export function listFreeformSearches({ limit, offsetId, order, sorting, userId, apiKeyId, isSaved }: { + limit?: number; + offsetId?: string; + order?: "ASC" | "DESC"; + sorting?: "created_at"; + userId?: string; + apiKeyId?: string; + isSaved?: boolean; +} = {}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: { + data: ScreeningFreeformSearchDto[]; + has_next_page: boolean; + }; + } | { + status: 401; + data: string; + } | { + status: 403; + data: string; + }>(`/screenings/freeform-search${QS.query(QS.explode({ + limit, + offset_id: offsetId, + order, + sorting, + user_id: userId, + api_key_id: apiKeyId, + is_saved: isSaved + }))}`, { + ...opts + })); +} /** * Retrieve the freshness of sanction datasets */ From 3d9907e57b5314f7e35802594d7c365106077100 Mon Sep 17 00:00:00 2001 From: Pascal Delange Date: Fri, 5 Jun 2026 13:32:58 +0200 Subject: [PATCH 02/23] openapi file for listing of freeform searches --- .../openapis/marblecore-api/screenings.yml | 95 +++++++++++++++++++ .../src/generated/marblecore-api.ts | 29 ++++-- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/packages/marble-api/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 4bbe6137d..501a3566a 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -361,6 +361,44 @@ type: array items: $ref: '#/components/schemas/ScreeningMatchDto' + get: + tags: + - Screening + summary: List past freeform searches + operationId: listFreeformSearches + parameters: + - $ref: 'components.yml#/parameters/limit' + - $ref: 'components.yml#/parameters/offset_id' + - $ref: 'components.yml#/parameters/order' + - name: sorting + description: the field used to sort the items + in: query + required: false + schema: + type: string + enum: + - created_at + responses: + '200': + description: The list of past freeform searches + content: + application/json: + schema: + type: object + required: + - data + - has_next_page + properties: + data: + type: array + items: + $ref: '#/components/schemas/ScreeningFreeformSearchDto' + has_next_page: + type: boolean + '401': + $ref: 'components.yml#/responses/401' + '403': + $ref: 'components.yml#/responses/403' components: schemas: @@ -935,6 +973,63 @@ components: type: integer minimum: 0 maximum: 100 + ScreeningFreeformSearchDto: + type: object + required: + - id + - created_at + - search_input + - search_config + properties: + id: + type: string + format: uuid + user_id: + type: string + format: uuid + api_key_id: + type: string + format: uuid + created_at: + type: string + format: date-time + search_input: + $ref: '#/components/schemas/ScreeningFreeformSearchInputDto' + search_config: + $ref: '#/components/schemas/ScreeningFreeformSearchConfigDto' + ScreeningFreeformSearchInputDto: + type: object + required: + - type + - query + properties: + type: + type: string + enum: ['Thing', 'Person', 'Organization', 'Vehicle'] + query: + type: object + additionalProperties: + type: array + items: + type: string + ScreeningFreeformSearchConfigDto: + type: object + required: + - provider + - filters + - limit + properties: + provider: + type: string + filters: + $ref: '#/components/schemas/ScreeningConfigBodyFiltersDto' + threshold: + type: integer + nullable: true + minimum: 0 + maximum: 100 + limit: + type: integer RefineQueryDto: type: object description: One of Thing, Person, Organization, or Vehicle must be provided diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index 79725def8..df9f3fcd8 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -4180,19 +4180,33 @@ export function freeformSearch(body?: { /** * List past freeform searches */ -export function listFreeformSearches({ limit, offsetId, order, sorting, userId, apiKeyId, isSaved }: { +export function listFreeformSearches({ limit, offsetId, order, sorting }: { limit?: number; offsetId?: string; order?: "ASC" | "DESC"; sorting?: "created_at"; - userId?: string; - apiKeyId?: string; - isSaved?: boolean; } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: { - data: ScreeningFreeformSearchDto[]; + data: { + id: string; + user_id?: string; + api_key_id?: string; + created_at: string; + search_input: { + "type": "Thing" | "Person" | "Organization" | "Vehicle"; + query: { + [key: string]: string[]; + }; + }; + search_config: { + provider: string; + filters: ScreeningConfigBodyFiltersDto; + threshold?: number | null; + limit: number; + }; + }[]; has_next_page: boolean; }; } | { @@ -4205,10 +4219,7 @@ export function listFreeformSearches({ limit, offsetId, order, sorting, userId, limit, offset_id: offsetId, order, - sorting, - user_id: userId, - api_key_id: apiKeyId, - is_saved: isSaved + sorting }))}`, { ...opts })); From cd2bc1f31e24ee345689b6d98b53217e0c155028 Mon Sep 17 00:00:00 2001 From: Pascal Delange Date: Fri, 5 Jun 2026 13:35:45 +0200 Subject: [PATCH 03/23] rm dto field --- packages/marble-api/openapis/marblecore-api/screenings.yml | 3 --- packages/marble-api/src/generated/marblecore-api.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/packages/marble-api/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 501a3566a..4dbc2fa98 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -960,9 +960,6 @@ components: required: - query properties: - screening_id: - type: string - format: uuid query: $ref: '#/components/schemas/RefineQueryDto' datasets: diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index df9f3fcd8..77dc59210 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -4137,7 +4137,6 @@ export function refineScreening(screeningRefineDto?: ScreeningRefineDto, opts?: * Freeform search for sanctions matches */ export function freeformSearch(body?: { - screening_id?: string; /** One of Thing, Person, Organization, or Vehicle must be provided */ query: { Thing?: { From d594c592360768836d56dfb91ba9acd925174519 Mon Sep 17 00:00:00 2001 From: Pascal Delange Date: Fri, 5 Jun 2026 16:24:21 +0200 Subject: [PATCH 04/23] filter query params --- .../openapis/marblecore-api/screenings.yml | 20 +++++++++++++++++++ .../src/generated/marblecore-api.ts | 10 ++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/marble-api/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 4dbc2fa98..21f13ca6a 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -378,6 +378,26 @@ type: string enum: - created_at + - name: user_id + description: filter searches by user ID + in: query + required: false + schema: + type: string + format: uuid + - name: api_key_id + description: filter searches by API key ID + in: query + required: false + schema: + type: string + format: uuid + - name: is_saved + description: show only searches that have been manually saved + in: query + required: false + schema: + type: boolean responses: '200': description: The list of past freeform searches diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index 77dc59210..9bb44b825 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -4179,11 +4179,14 @@ export function freeformSearch(body?: { /** * List past freeform searches */ -export function listFreeformSearches({ limit, offsetId, order, sorting }: { +export function listFreeformSearches({ limit, offsetId, order, sorting, userId, apiKeyId, isSaved }: { limit?: number; offsetId?: string; order?: "ASC" | "DESC"; sorting?: "created_at"; + userId?: string; + apiKeyId?: string; + isSaved?: boolean; } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -4218,7 +4221,10 @@ export function listFreeformSearches({ limit, offsetId, order, sorting }: { limit, offset_id: offsetId, order, - sorting + sorting, + user_id: userId, + api_key_id: apiKeyId, + is_saved: isSaved }))}`, { ...opts })); From 4940a1af42025d9420c72d926bb821fe279b834f Mon Sep 17 00:00:00 2001 From: Pascal Delange Date: Fri, 5 Jun 2026 22:53:06 +0200 Subject: [PATCH 05/23] updated output of freeform search: return id and array of matches --- .../FreeformSearch/FreeformSearchForm.tsx | 5 ++++- .../FreeformSearch/FreeformSearchPage.tsx | 8 +++++--- .../src/queries/screening/freeform-search.ts | 4 +++- .../src/repositories/ScreeningRepository.ts | 9 ++++++--- .../app-builder/src/server-fns/screenings.ts | 4 ++-- .../openapis/marblecore-api/screenings.yml | 19 +++++++++++++++---- .../src/generated/marblecore-api.ts | 5 ++++- 7 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx index b03b4c38a..e1d6c1236 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx @@ -28,7 +28,10 @@ import { EntitySearchFormProvider } from './entity-search-form-context'; import { DEFAULT_LIMIT, LimitPopover } from './LimitPopover'; interface FreeformSearchFormProps { - onSearchComplete: (results: ScreeningMatchPayload[], searchInputs: FreeformSearchInput) => void; + onSearchComplete: ( + result: { id: string; matches: ScreeningMatchPayload[] }, + searchInputs: FreeformSearchInput, + ) => void; listConfig: ListConfigFilters; } diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPage.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPage.tsx index 5ed8fc996..ce11bd99b 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPage.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPage.tsx @@ -8,6 +8,7 @@ import { FreeformSearchForm } from './FreeformSearchForm'; import { FreeformSearchResults } from './FreeformSearchResults'; export interface FreeformSearchState { + searchId: string; results: ScreeningMatchPayload[]; inputs: { entityType: SearchableSchema; @@ -29,13 +30,14 @@ export const FreeformSearchPage: FunctionComponent = ({ const [searchTerm, setSearchTerm] = useState(undefined); const handleSearchComplete = useCallback( - (data: ScreeningMatchPayload[], inputs: FreeformSearchInput) => { - setResults(data); + (result: { id: string; matches: ScreeningMatchPayload[] }, inputs: FreeformSearchInput) => { + setResults(result.matches); setCurrentLimit(inputs.limit); // Extract the 'name' field value as the search term for highlighting setSearchTerm(inputs.fields.name as string | undefined); onSearchComplete?.({ - results: data, + searchId: result.id, + results: result.matches, inputs: { entityType: inputs.entityType, fields: inputs.fields as Record, diff --git a/packages/app-builder/src/queries/screening/freeform-search.ts b/packages/app-builder/src/queries/screening/freeform-search.ts index 4f9b46f70..b70db8273 100644 --- a/packages/app-builder/src/queries/screening/freeform-search.ts +++ b/packages/app-builder/src/queries/screening/freeform-search.ts @@ -11,7 +11,9 @@ import { import { useMutation, useQuery } from '@tanstack/react-query'; import { useServerFn } from '@tanstack/react-start'; -type FreeformSearchResponse = { success: true; data: ScreeningMatchPayload[] } | { success: false; error: unknown }; +type FreeformSearchResponse = + | { success: true; data: { id: string; matches: ScreeningMatchPayload[] } } + | { success: false; error: unknown }; export const useFreeformSearchMutation = () => { const freeformSearch = useServerFn(freeformSearchFn); diff --git a/packages/app-builder/src/repositories/ScreeningRepository.ts b/packages/app-builder/src/repositories/ScreeningRepository.ts index 86b9081b5..aae007adf 100644 --- a/packages/app-builder/src/repositories/ScreeningRepository.ts +++ b/packages/app-builder/src/repositories/ScreeningRepository.ts @@ -52,7 +52,7 @@ export interface ScreeningRepository { datasets?: string[]; threshold?: number; limit?: number; - }): Promise; + }): Promise<{ id: string; matches: ScreeningMatchPayload[] }>; getAiSuggestions(args: { screeningId: string }): Promise; enrichedData(args: { entityId: string }): Promise; getAvailableFilters(args: { feature: AvailableFeatures }): Promise; @@ -152,8 +152,11 @@ export function makeGetScreeningRepository() { filters: createScreeningFilters(datasets ?? []), threshold, }; - const results = await marbleCoreApiClient.freeformSearch(dto, { limit }); - return R.map(results, (result) => adaptScreeningMatchPayload(result.payload)); + const { id, matches } = await marbleCoreApiClient.freeformSearch(dto, { limit }); + return { + id, + matches: R.map(matches, (match) => adaptScreeningMatchPayload(match.payload)), + }; }, getAiSuggestions: async ({ screeningId }) => { return R.map(await marbleCoreApiClient.getScreeningAiSuggestions(screeningId), adaptScreeningAiSuggestion); diff --git a/packages/app-builder/src/server-fns/screenings.ts b/packages/app-builder/src/server-fns/screenings.ts index 5e3c741dd..a9190924b 100644 --- a/packages/app-builder/src/server-fns/screenings.ts +++ b/packages/app-builder/src/server-fns/screenings.ts @@ -192,8 +192,8 @@ export const freeformSearchFn = createServerFn({ method: 'POST' }) .inputValidator(freeformSearchSchema) .handler(async ({ context, data }) => { try { - const results = await context.authInfo.screening.freeformSearch(data); - return { success: true as const, data: results as ScreeningMatchPayload[] }; + const result = await context.authInfo.screening.freeformSearch(data); + return { success: true as const, data: result }; } catch { return { success: false as const, error: 'Freeform search failed' }; } diff --git a/packages/marble-api/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 21f13ca6a..1f4db19aa 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -354,13 +354,11 @@ $ref: '#/components/schemas/ScreeningFreeformDto' responses: '200': - description: The list of matches + description: The freeform search id and its matches content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/ScreeningMatchDto' + $ref: '#/components/schemas/ScreeningFreeformSearchResultDto' get: tags: - Screening @@ -990,6 +988,19 @@ components: type: integer minimum: 0 maximum: 100 + ScreeningFreeformSearchResultDto: + type: object + required: + - id + - matches + properties: + id: + type: string + format: uuid + matches: + type: array + items: + $ref: '#/components/schemas/ScreeningMatchDto' ScreeningFreeformSearchDto: type: object required: diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index 9bb44b825..d561510e5 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -4167,7 +4167,10 @@ export function freeformSearch(body?: { } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: ScreeningMatchDto[]; + data: { + id: string; + matches: ScreeningMatchDto[]; + }; }>(`/screenings/freeform-search${QS.query(QS.explode({ limit }))}`, oazapfts.json({ From 290bd8e87a16a3113dd8d58aa34b441b1f42938e Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Tue, 9 Jun 2026 14:14:25 +0200 Subject: [PATCH 06/23] update api --- .../src/generated/marblecore-api.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index d561510e5..455babfe8 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -4194,24 +4194,7 @@ export function listFreeformSearches({ limit, offsetId, order, sorting, userId, return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: { - data: { - id: string; - user_id?: string; - api_key_id?: string; - created_at: string; - search_input: { - "type": "Thing" | "Person" | "Organization" | "Vehicle"; - query: { - [key: string]: string[]; - }; - }; - search_config: { - provider: string; - filters: ScreeningConfigBodyFiltersDto; - threshold?: number | null; - limit: number; - }; - }[]; + data: ScreeningFreeformSearchDto[]; has_next_page: boolean; }; } | { From acfe4a15faf0e2ca92258f3064536244445fd69b Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Thu, 11 Jun 2026 10:36:15 +0200 Subject: [PATCH 07/23] dedup associations --- .../components/Screenings/MatchDetails.tsx | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/MatchDetails.tsx b/packages/app-builder/src/components/Screenings/MatchDetails.tsx index 1c60d9f65..cf4ff3029 100644 --- a/packages/app-builder/src/components/Screenings/MatchDetails.tsx +++ b/packages/app-builder/src/components/Screenings/MatchDetails.tsx @@ -4,7 +4,6 @@ import { ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Modal } from 'ui-design-system'; import { Icon } from 'ui-icons'; - import { EntityProperties } from './EntityProperties'; import { Associations } from './MatchCard/Associations'; import { FamilyDetail } from './MatchCard/FamilyDetail'; @@ -40,17 +39,45 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp const deduplicatedEntity = useMemo(() => { if (entity.schema !== 'Person') return entity; - + // family person & relative const familyPersonIds = new Set(entity.properties?.familyPerson?.flatMap(({ properties }) => properties?.person)); if (!familyPersonIds.size) return entity; + const familyRelative = entity.properties.familyRelative?.filter( + ({ properties }) => !properties.relative?.some((relativeId) => familyPersonIds.has(relativeId)), + ); + // assocations + const associateIds = new Set( + entity.properties?.associations?.flatMap(({ properties }) => properties?.person?.map((p) => p.id)), + ); + const ignored: Array<{ id: string; relationship: string[] }> = []; + const associations = entity.properties?.associations?.filter(({ properties }) => { + if (!properties?.person) return false; + if (properties.person?.some((p) => associateIds.has(p.id))) { + const personId = properties.person?.find((p) => associateIds.has(p.id))?.id; + associateIds.delete(personId); + return true; + } + properties.person.forEach((p) => ignored.push({ id: p.id, relationship: properties.relationship! })); + return false; + }); + // complete relationships of original associations with ignored relationships (only once) + ignored.forEach((person) => { + const association = entity.properties?.associations?.find(({ properties }) => + properties?.person?.some((p) => p.id === person.id), + ); + if (association) { + const relationships = new Set(association.properties.relationship ?? []); + person.relationship.forEach((r) => relationships.add(r)); + association.properties.relationship = Array.from(relationships); + } + }); return { ...entity, properties: { ...entity.properties, - familyRelative: entity.properties.familyRelative?.filter( - ({ properties }) => !properties.relative?.some((relativeId) => familyPersonIds.has(relativeId)), - ), + familyRelative, + associations, }, }; }, [entity]); @@ -108,7 +135,7 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp ) : null} - + {entity.schema === 'Person' && deduplicatedEntity.properties?.['familyPerson']?.length ? ( From 3a1cc08c7186ac8a7a828a6c8bf3bfa2b1e40a13 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Thu, 11 Jun 2026 15:13:36 +0200 Subject: [PATCH 08/23] Refactor DataField components to improve structure and reusability - Introduced new components for string, email, phone, country, link, and date visualizations. - Enhanced the StringCountryComponent to conditionally display country names. - Added a PropertyContainer component in EntityProperties for better property handling. - Updated property metadata to include formats for various data types. - Added a new 'dot' icon to the icon library for UI enhancements. --- .../Data/DataVisualisation/DataField.tsx | 70 +++++--- .../Screenings/EntityProperties.tsx | 16 +- .../src/constants/screening-entity.tsx | 149 ++++++++++++------ packages/ui-icons/src/generated/icon-names.ts | 1 + .../src/generated/icons-svg-sprite.svg | 2 +- packages/ui-icons/svgs/icons/dot.svg | 3 + 6 files changed, 168 insertions(+), 73 deletions(-) create mode 100644 packages/ui-icons/svgs/icons/dot.svg diff --git a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx index b35a7a41e..70d42a5a0 100644 --- a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx +++ b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx @@ -245,18 +245,30 @@ function EmptyValue({ className }: { className?: string }) { function StringMain() { const value = useStringValue(); if (!value) return ; + return ; +} + +export function StringMainComponent({ value }: { value: string }) { return {value}; } function StringCode() { const value = useStringValue(); if (!value) return ; + return StringCodeComponent({ value }); +} + +export function StringCodeComponent({ value }: { value: string }) { return {value}; } function StringEmail() { const value = useStringValue(); if (!value) return ; + return StringEmailComponent({ value }); +} + +export function StringEmailComponent({ value }: { value: string }) { const isValid = z.email().safeParse(value).success; if (!isValid) return {value}; return ( @@ -269,6 +281,10 @@ function StringEmail() { function StringPhone() { const value = useStringValue(); if (!value) return ; + return StringPhoneComponent({ value }); +} + +export function StringPhoneComponent({ value }: { value: string }) { const phone = parsePhoneNumber(value); const strPhone = phone ? phone.formatInternational() : value; if (phone) { @@ -290,15 +306,25 @@ function StringCity() { function StringCountry() { const value = useStringValue(); - const language = useFormatLanguage(); if (!value) return ; + return StringCountryComponent({ value }); +} + +export function StringCountryComponent({ + value, + withCountryName = true, +}: { + value: string; + withCountryName?: boolean; +}) { + const language = useFormatLanguage(); const result = tryCatch(() => CountryFlag.byCountryCode(value.toUpperCase())); if (!result.ok) return {value}; const country = result.value; return ( {country.flag} - {formatCountryName(country.isoAlpha2, language)} + {withCountryName && {formatCountryName(country.isoAlpha2, language)}} ); } @@ -306,6 +332,10 @@ function StringCountry() { function StringLink() { const value = useStringValue(); if (!value) return ; + return StringLinkComponent({ value }); +} + +export function StringLinkComponent({ value }: { value: string }) { const result = tryCatch(() => new URL(value)); if (!result.ok || !['http:', 'https:'].includes(result.value.protocol)) return {value}; return ( @@ -366,19 +396,21 @@ function StringFree() { function DateBirthdate() { const value = useStringValue(); + if (!value) return ; + return DateBirthdateComponent({ value }); +} + +export function DateBirthdateComponent({ value }: { value: string }) { const formatDateTime = useFormatDateTime(); const language = useFormatLanguage(); - if (value) { - const date = new Date(value); - const age = formatAge(date, language); - return ( - - {age} - {formatDateTime(date, { dateStyle: 'short' })} - - ); - } - return ; + const date = new Date(value); + const age = formatAge(date, language); + return ( + + {age} + {formatDateTime(date, { dateStyle: 'short' })} + + ); } function StringIban() { @@ -405,12 +437,14 @@ function StringCurrency() { function DateDatetime() { const value = useStringValue(); + if (!value) return ; + return DateDatetimeComponent({ value }); +} + +export function DateDatetimeComponent({ value, withTime = true }: { value: string; withTime?: boolean }) { const formatDateTime = useFormatDateTime(); - if (value) { - const date = new Date(value); - return {formatDateTime(date, { dateStyle: 'short', timeStyle: 'short' })}; - } - return ; + const date = new Date(value); + return {formatDateTime(date, { dateStyle: 'short', timeStyle: withTime ? 'short' : undefined })}; } function DataGpsCoords() { diff --git a/packages/app-builder/src/components/Screenings/EntityProperties.tsx b/packages/app-builder/src/components/Screenings/EntityProperties.tsx index 0de44fc8c..ac728260c 100644 --- a/packages/app-builder/src/components/Screenings/EntityProperties.tsx +++ b/packages/app-builder/src/components/Screenings/EntityProperties.tsx @@ -1,6 +1,7 @@ import { createPropertyTransformer, getSanctionEntityProperties, + isPropertyListed, type PropertyForSchema, type ScreeningEntityProperty, } from '@app-builder/constants/screening-entity'; @@ -8,7 +9,7 @@ import { type OpenSanctionEntity } from '@app-builder/models/screening'; import { useFormatLanguage } from '@app-builder/utils/format'; import { Fragment, type ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; - +import { Icon } from 'ui-icons'; import { screeningsI18n } from './screenings-i18n'; export function EntityProperties({ @@ -70,11 +71,13 @@ export function EntityProperties({ {values.length > 0 ? ( - <> + {values.map((v, i) => ( - {i === values.length - 1 ? null : ·} + {i === values.length - 1 || property === 'address' ? null : ( + + )} ))} {restItemsCount > 0 ? ( @@ -91,7 +94,7 @@ export function EntityProperties({ ) : null} - + ) : ( not available )} @@ -103,3 +106,8 @@ export function EntityProperties({ ); } + +function PropertyContainer({ property, children }: { property: ScreeningEntityProperty; children: ReactNode }) { + if (isPropertyListed(property)) return
    {children}
; + return {children}; +} diff --git a/packages/app-builder/src/constants/screening-entity.tsx b/packages/app-builder/src/constants/screening-entity.tsx index 828e970ae..3a43ba2d7 100644 --- a/packages/app-builder/src/constants/screening-entity.tsx +++ b/packages/app-builder/src/constants/screening-entity.tsx @@ -1,6 +1,15 @@ +import { + DateBirthdateComponent, + StringCodeComponent, + StringCountryComponent, + StringEmailComponent, + StringPhoneComponent, +} from '@app-builder/components/Data/DataVisualisation/DataField'; import { ExternalLink } from '@app-builder/components/ExternalLink'; import { HighlightText } from '@app-builder/components/Screenings/HighlightText'; import { type OpenSanctionEntitySchema } from '@app-builder/models/screening'; +import { match } from 'ts-pattern'; +import { Icon } from 'ui-icons'; export type PropertyDataType = 'string' | 'country' | 'url' | 'date' | 'wikidataId'; export type PropertyForSchema< @@ -140,49 +149,66 @@ const schemaInheritence = { MembershipMember: null, } satisfies Record; -const propertyMetadata = { - address: { type: 'string' }, +type PropertyFormat = + | 'monospace' + | 'date' + | 'dateTime' + | 'dateOfBirth' + | 'country' + | 'countryFlag' + | 'address' + | 'position' + | 'email' + | 'phone'; + +type PropertyMetadata = { + type: PropertyDataType; + format?: PropertyFormat; +}; + +const propertyMetadata: Record = { + address: { type: 'string', format: 'address' }, alias: { type: 'string' }, appearance: { type: 'string' }, - birthDate: { type: 'date' }, - citizenship: { type: 'country' }, + birthDate: { type: 'string', format: 'dateOfBirth' }, + citizenship: { type: 'string', format: 'country' }, classification: { type: 'string' }, - country: { type: 'country' }, - createdAt: { type: 'date' }, - deathDate: { type: 'date' }, + country: { type: 'string', format: 'country' }, + createdAt: { type: 'string', format: 'dateTime' }, + deathDate: { type: 'string', format: 'date' }, description: { type: 'string' }, - dissolutionDate: { type: 'date' }, - dunsCode: { type: 'string' }, + dissolutionDate: { type: 'string', format: 'date' }, + dunsCode: { type: 'string', format: 'monospace' }, education: { type: 'string' }, - email: { type: 'string' }, + email: { type: 'string', format: 'email' }, ethnicity: { type: 'string' }, eyeColor: { type: 'string' }, fatherName: { type: 'string' }, firstName: { type: 'string' }, - gender: { type: 'string' }, + gender: { type: 'string', format: 'monospace' }, hairColor: { type: 'string' }, height: { type: 'string' }, icijId: { type: 'string' }, - idNumber: { type: 'string' }, - incorporationDate: { type: 'date' }, + idNumber: { type: 'string', format: 'monospace' }, + incorporationDate: { type: 'string', format: 'date' }, innCode: { type: 'string' }, - jurisdiction: { type: 'country' }, + jurisdiction: { type: 'string', format: 'country' }, keywords: { type: 'string' }, lastName: { type: 'string' }, legalForm: { type: 'string' }, leiCode: { type: 'string' }, - mainCountry: { type: 'country' }, + mainCountry: { type: 'string', format: 'country' }, middleName: { type: 'string' }, motherName: { type: 'string' }, name: { type: 'string' }, nameSuffix: { type: 'string' }, - nationality: { type: 'country' }, + nationality: { type: 'string', format: 'country' }, notes: { type: 'string' }, npiCode: { type: 'string' }, ogrnCode: { type: 'string' }, okpoCode: { type: 'string' }, opencorporatesUrl: { type: 'url' }, - passportNumber: { type: 'string' }, + passportNumber: { type: 'string', format: 'monospace' }, phone: { type: 'string' }, political: { type: 'string' }, position: { type: 'string' }, @@ -208,13 +234,15 @@ const propertyMetadata = { wikidataId: { type: 'wikidataId' }, authority: { type: 'string' }, authorityId: { type: 'string' }, - startDate: { type: 'date' }, - endDate: { type: 'date' }, + startDate: { type: 'string', format: 'date' }, + endDate: { type: 'string', format: 'date' }, programId: { type: 'string' }, programUrl: { type: 'url' }, reason: { type: 'string' }, - listingDate: { type: 'date' }, -} satisfies Record; + listingDate: { type: 'string', format: 'dateTime' }, +}; + +export const propertyMetadataList: Array = ['address']; export function getSanctionEntityProperties(schema: OpenSanctionEntitySchema) { let currentSchema: OpenSanctionEntitySchema | null = schema; @@ -228,44 +256,33 @@ export function getSanctionEntityProperties(schema: OpenSanctionEntitySchema) { return properties; } -export function createPropertyTransformer(ctx: { language: string; formatLanguage: string; highlightText?: string }) { - const intlCountry = new Intl.DisplayNames(ctx.language, { type: 'region' }); - const intlDate = new Intl.DateTimeFormat(ctx.formatLanguage, { - dateStyle: 'short', - timeStyle: undefined, - }); +export function isPropertyListed(property: ScreeningEntityProperty) { + return propertyMetadataList.includes(property); +} +export function createPropertyTransformer(ctx: { language: string; formatLanguage: string; highlightText?: string }) { return function TransformProperty({ property, value }: { property: ScreeningEntityProperty; value: string }) { - const dataType = propertyMetadata[property].type; - switch (dataType) { + const { type, format } = propertyMetadata[property]; + switch (type) { case 'string': - return value.includes('\n') ? ( - value - .split('\n') - .map((v, index) => - v ? ( - - ) : ( -
- ), - ) - ) : ( - - ); + return value.includes('\n') + ? value + .split('\n') + .map((v, index) => + v ? ( + + ) : ( +
+ ), + ) + : formatedValue(format, value, ctx.highlightText); case 'url': return ( {value} ); - case 'country': - try { - return {intlCountry.of(value.toUpperCase())}; - } catch { - return value.toUpperCase(); - } - case 'date': - return ; + case 'wikidataId': return ( @@ -275,3 +292,35 @@ export function createPropertyTransformer(ctx: { language: string; formatLanguag } }; } + +function formatedValue(format: PropertyFormat | undefined, value: string, highlightText?: string) { + return match(format) + .with('monospace', () => StringCodeComponent({ value })) + .with('date', 'dateTime', () => ) + .with('dateOfBirth', () => DateBirthdateComponent({ value })) + .with('country', () => StringCountryComponent({ value, withCountryName: true })) + .with('countryFlag', () => StringCountryComponent({ value, withCountryName: false })) + .with('address', () => ) + .with('position', () => {value}) + .with('email', () => StringEmailComponent({ value })) + .with('phone', () => StringPhoneComponent({ value })) + .with(undefined, () => ) + .exhaustive(); +} + +function ParseAddress({ address }: { address: string }) { + const addressParts = address.split(','); + return ( +
  • + + {addressParts.map((part, index) => ( +
    + {part} + {index < addressParts.length - 1 ? ( + + ) : null} +
    + ))} +
  • + ); +} diff --git a/packages/ui-icons/src/generated/icon-names.ts b/packages/ui-icons/src/generated/icon-names.ts index 5e0e9f0b6..9141ce395 100644 --- a/packages/ui-icons/src/generated/icon-names.ts +++ b/packages/ui-icons/src/generated/icon-names.ts @@ -43,6 +43,7 @@ export const iconNames = [ 'delete', 'denied', 'dns', + 'dot', 'dots-three', 'download', 'draft', diff --git a/packages/ui-icons/src/generated/icons-svg-sprite.svg b/packages/ui-icons/src/generated/icons-svg-sprite.svg index 6b437c299..642f16038 100644 --- a/packages/ui-icons/src/generated/icons-svg-sprite.svg +++ b/packages/ui-icons/src/generated/icons-svg-sprite.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui-icons/svgs/icons/dot.svg b/packages/ui-icons/svgs/icons/dot.svg new file mode 100644 index 000000000..c48d0b931 --- /dev/null +++ b/packages/ui-icons/svgs/icons/dot.svg @@ -0,0 +1,3 @@ + + + From b64b0c54b22bc1fe18d6177bd0ed398893a2a2b5 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Thu, 11 Jun 2026 15:15:56 +0200 Subject: [PATCH 09/23] comment save search --- .../Screenings/FreeformSearch/SaveSearch.tsx | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx index 3b2b567cb..a356dcb0f 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx @@ -2,11 +2,11 @@ import { FormErrorOrDescription } from '@app-builder/components/Form/Tanstack/Fo import { FormInput } from '@app-builder/components/Form/Tanstack/FormInput'; import { FormLabel } from '@app-builder/components/Form/Tanstack/FormLabel'; import { useEntityName } from '@app-builder/hooks/useEntityName'; -import { useSaveFreeformSearchMutation } from '@app-builder/queries/screening/freeform-search'; +// import { useSaveFreeformSearchMutation } from '@app-builder/queries/screening/freeform-search'; import { getFieldErrors, handleSubmit } from '@app-builder/utils/form'; import { useForm } from '@tanstack/react-form'; import { useState } from 'react'; -import toast from 'react-hot-toast'; +// import toast from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; import { Button, Modal } from 'ui-design-system'; import { Icon } from 'ui-icons'; @@ -20,29 +20,29 @@ const saveSearchFormSchema = z.object({ export const SaveSearch = ({ search }: { search: FreeformSearchState }) => { const { t } = useTranslation(['screenings', 'common']); const [open, setOpen] = useState(false); - const saveSearchMutation = useSaveFreeformSearchMutation(); + // const saveSearchMutation = useSaveFreeformSearchMutation(); const { getEntityName } = useEntityName(); const form = useForm({ defaultValues: { name: '' }, onSubmit: ({ value, formApi }) => { if (!formApi.state.isValid) return; - saveSearchMutation - .mutateAsync({ - name: value.name, - inputs: search.inputs, - results: search.results, - }) - .then((res) => { - if (res.success) { - toast.success(t('screenings:freeform_search.save.success')); - setOpen(false); - form.reset(); - } else { - toast.error(t('common:errors.unknown')); - } - }) - .catch(() => toast.error(t('common:errors.unknown'))); + // saveSearchMutation + // .mutateAsync({ + // name: value.name, + // inputs: search.inputs, + // results: search.results, + // }) + // .then((res) => { + // if (res.success) { + // toast.success(t('screenings:freeform_search.save.success')); + // setOpen(false); + // form.reset(); + // } else { + // toast.error(t('common:errors.unknown')); + // } + // }) + // .catch(() => toast.error(t('common:errors.unknown'))); }, validators: { onSubmit: saveSearchFormSchema, @@ -124,10 +124,10 @@ export const SaveSearch = ({ search }: { search: FreeformSearchState }) => { {t('common:cancel')} - + */} From fa177d1d61beeaaa0f623a9e951d050492a0b265 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Thu, 11 Jun 2026 19:01:16 +0200 Subject: [PATCH 10/23] Enhance DataField and Screening components for improved functionality and UI - Updated DataField components to support optional children in StringCodeComponent and added monospaced formatting in DateDatetimeComponent. - Refactored EntityProperties to utilize a new IconDot component for better visual consistency. - Improved MatchDetails and FamilyDetail components to handle family relationships more effectively, including deduplication of relationships. - Introduced isDisplayableTopic utility in TopicTag to filter out non-displayable topics. - Adjusted layout in FreeformMatchCard and MemberShip components for better responsiveness. - Enhanced ExpandableGroupTagLine to manage item visibility more effectively based on container width. --- .../Data/DataVisualisation/DataField.tsx | 22 +- .../Screenings/EntityProperties.tsx | 8 +- .../FreeformSearch/FreeformMatchCard.tsx | 2 +- .../Screenings/MatchCard/Associations.tsx | 191 ++++++++++------ .../Screenings/MatchCard/FamilyDetail.tsx | 216 ++++++++++++------ .../Screenings/MatchCard/MemberShip.tsx | 2 +- .../components/Screenings/MatchDetails.tsx | 147 +++++++++--- .../src/components/Screenings/TopicTag.tsx | 11 +- .../src/constants/screening-entity.tsx | 96 +++++++- packages/app-builder/src/models/screening.ts | 9 + .../ExpandableGroupTagLine.spec.tsx | 37 ++- .../ExpandableGroupTagLine.tsx | 24 +- .../src/SelectCountry/SelectCountry.tsx | 6 + 13 files changed, 558 insertions(+), 213 deletions(-) diff --git a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx index 70d42a5a0..a0b7c0cfe 100644 --- a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx +++ b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx @@ -13,7 +13,7 @@ import { tryCatch } from '@app-builder/utils/tryCatch'; import CountryFlag from 'country-flag-emojis'; import cc from 'currency-codes'; import parsePhoneNumber from 'libphonenumber-js/min'; -import { type ComponentType, Fragment, lazy, Suspense, useMemo, useState } from 'react'; +import { type ComponentType, Fragment, lazy, ReactNode, Suspense, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isNonNullish } from 'remeda'; import { match, P } from 'ts-pattern'; @@ -258,8 +258,8 @@ function StringCode() { return StringCodeComponent({ value }); } -export function StringCodeComponent({ value }: { value: string }) { - return {value}; +export function StringCodeComponent({ value, children }: { value?: string; children?: ReactNode }) { + return {value ?? children ?? '-'}; } function StringEmail() { @@ -441,10 +441,22 @@ function DateDatetime() { return DateDatetimeComponent({ value }); } -export function DateDatetimeComponent({ value, withTime = true }: { value: string; withTime?: boolean }) { +export function DateDatetimeComponent({ + value, + withTime = true, + monospaced = false, +}: { + value: string; + withTime?: boolean; + monospaced?: boolean; +}) { const formatDateTime = useFormatDateTime(); const date = new Date(value); - return {formatDateTime(date, { dateStyle: 'short', timeStyle: withTime ? 'short' : undefined })}; + return ( + + {formatDateTime(date, { dateStyle: 'short', timeStyle: withTime ? 'short' : undefined })} + + ); } function DataGpsCoords() { diff --git a/packages/app-builder/src/components/Screenings/EntityProperties.tsx b/packages/app-builder/src/components/Screenings/EntityProperties.tsx index ac728260c..c955dc02c 100644 --- a/packages/app-builder/src/components/Screenings/EntityProperties.tsx +++ b/packages/app-builder/src/components/Screenings/EntityProperties.tsx @@ -1,6 +1,7 @@ import { createPropertyTransformer, getSanctionEntityProperties, + IconDot, isPropertyListed, type PropertyForSchema, type ScreeningEntityProperty, @@ -9,7 +10,6 @@ import { type OpenSanctionEntity } from '@app-builder/models/screening'; import { useFormatLanguage } from '@app-builder/utils/format'; import { Fragment, type ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Icon } from 'ui-icons'; import { screeningsI18n } from './screenings-i18n'; export function EntityProperties({ @@ -75,14 +75,12 @@ export function EntityProperties({ {values.map((v, i) => ( - {i === values.length - 1 || property === 'address' ? null : ( - - )} + {i === values.length - 1 || property === 'address' ? null : } ))} {restItemsCount > 0 ? ( <> - · + {isPropertyListed(property) ? null : } + )} - + ); }; diff --git a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx index c9bca938e..17674c19a 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx @@ -1,10 +1,19 @@ -import { FamilyPersonEntity, FamilyRelativeEntity, PersonEntity } from '@app-builder/models/screening'; -import { useFormatDateTime } from '@app-builder/utils/format'; +import { IconDot } from '@app-builder/constants/screening-entity'; +import { + type FamilyPersonEntity, + type FamilyRelationshipEntry, + type FamilyRelativeEntity, + PersonEntity, +} from '@app-builder/models/screening'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as R from 'remeda'; -import { Collapsible } from 'ui-design-system'; +import { cn, ExpandableGroupTagLine } from 'ui-design-system'; +import { Icon } from 'ui-icons'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; -import { TopicTag } from '../TopicTag'; +import { isDisplayableTopic, TopicTag } from '../TopicTag'; + +const MAX_FAMILY_MEMBERS = 5; export type RelationType = 'familyPerson' | 'familyRelative'; export type RelationEntity = T extends 'familyPerson' @@ -16,77 +25,140 @@ export type FamilyDetailProps = { familyMembers: RelationEntity; }; -export function FamilyDetail({ familyMembers, relation }: FamilyDetailProps) { - const formatDateTime = useFormatDateTime(); +type FamilyMemberRow = { + key: string; + member: FamilyPersonEntity | FamilyRelativeEntity; + id: string; + properties: PersonEntity['properties']; + relationshipEntries: FamilyRelationshipEntry[]; +}; +function FamilyRelationshipTag({ value, source }: FamilyRelationshipEntry) { const { t } = useTranslation(['screenings']); + const label = value + ? t(`screenings:relation.${R.toCamelCase(value)}.label`, { + defaultValue: value, + }) + : t('screenings:match.family.unknown_relationship'); return ( -
    -
    {t('screenings:match.family-members.title')}
    - - - {t('screenings:match.family-member.count', { count: familyMembers.length })} - - -
    - {familyMembers.map((member, memberIndex) => { - const entities = member.properties[relation === 'familyPerson' ? 'relative' : 'person'] as PersonEntity[]; - - return entities?.map(({ id, properties }, idx) => { - if (!properties?.name?.[0]) return null; - const rel = - member.properties.relationship - ?.map((relation) => - t(`screenings:relation.${R.toCamelCase(relation)}.label`, { - defaultValue: relation, - }), - ) - .join(' · ') ?? t('screenings:match.family.unknown_relationship'); - - return ( -
    -
    -
    - {properties.caption?.length > 0 ? ( -
    {properties.caption}
    - ) : ( -
    - {properties.alias?.[0] ?? properties.name?.[0]} -
    - )} -
    - {rel} - {member.properties.startDate?.[0] && ( - - {' '} - ({formatDateTime(member.properties.startDate[0], { dateStyle: 'medium' })} - {member.properties.endDate?.[0] && ( - <> - {' - '} - {formatDateTime(member.properties.endDate[0], { dateStyle: 'medium' })} - - )} - ) - - )} -
    - {properties['topics']?.length ? ( -
    - {getFilteredAndSortedTopics(properties['topics']).map((topic) => ( - - ))} -
    - ) : null} -
    -
    -
    - ); - }); - })} -
    -
    -
    -
    + + + {label} + + ); +} + +function flattenFamilyMembers( + familyMembers: RelationEntity, + relation: T, +): FamilyMemberRow[] { + const rows: FamilyMemberRow[] = []; + + familyMembers.forEach((member, memberIndex) => { + const entities = member.properties[relation === 'familyPerson' ? 'relative' : 'person'] as PersonEntity[]; + const relationshipEntries: FamilyRelationshipEntry[] = + member.properties.relationships ?? + (member.properties.relationship ?? []).map((value) => ({ value, source: relation })); + + entities?.forEach(({ id, properties }, idx) => { + if (!properties?.name?.[0]) return; + rows.push({ + key: `person-${memberIndex}-${id}-${idx}`, + member, + id, + properties, + relationshipEntries, + }); + }); + }); + + return rows; +} + +export function FamilyDetail({ familyMembers, relation }: FamilyDetailProps) { + const { t } = useTranslation(['screenings', 'common']); + const [showAll, setShowAll] = useState(false); + + const rows = useMemo(() => flattenFamilyMembers(familyMembers, relation), [familyMembers, relation]); + const hiddenCount = Math.max(0, rows.length - MAX_FAMILY_MEMBERS); + const visibleRows = showAll ? rows : rows.slice(0, MAX_FAMILY_MEMBERS); + + return ( +
      + {visibleRows.map((row, rowIndex) => { + const { key, member, id, properties, relationshipEntries } = row; + const isFirstElement = rowIndex === 0; + + const tags = properties.topics?.length + ? getFilteredAndSortedTopics(properties.topics) + .filter(isDisplayableTopic) + .map((topic) => ) + : []; + + const expandableItems = [ + , + properties.caption?.length > 0 ? ( + + {properties.caption} + + ) : ( + + {properties.alias?.[0] ?? properties.name?.[0]} + + ), + , + ...(relationshipEntries.length > 0 + ? relationshipEntries.map((entry, relIdx) => ( + + )) + : []), + ...(tags.length > 0 ? [, ...tags] : []), + ]; + + return ( +
    • +
      + {isFirstElement &&
      {t('screenings:match.family-members.title')}
      } +
      +
      + + + {member.properties.sourceUrl && member.properties.sourceUrl.length > 0 && ( + +
      {t('screenings:match.family.source.label')}
      +
        + {member.properties.sourceUrl.map((url, urlIdx) => ( +
      • + + {url} + +
      • + ))} +
      +
      + )} +
      +
    • + ); + })} + {hiddenCount > 0 && !showAll && ( +
    • + + +
    • + )} +
    ); } diff --git a/packages/app-builder/src/components/Screenings/MatchCard/MemberShip.tsx b/packages/app-builder/src/components/Screenings/MatchCard/MemberShip.tsx index 2e2e5de19..53d3a0e4f 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/MemberShip.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/MemberShip.tsx @@ -6,7 +6,7 @@ export const MemberShip = ({ membershipMember }: { membershipMember: MembershipM return ( <> -
    +
    {membershipMember?.map((membership, idx) => { return (
    diff --git a/packages/app-builder/src/components/Screenings/MatchDetails.tsx b/packages/app-builder/src/components/Screenings/MatchDetails.tsx index cf4ff3029..b4ca1941f 100644 --- a/packages/app-builder/src/components/Screenings/MatchDetails.tsx +++ b/packages/app-builder/src/components/Screenings/MatchDetails.tsx @@ -1,5 +1,11 @@ import { type PropertyForSchema } from '@app-builder/constants/screening-entity'; -import { type ScreeningMatch, type ScreeningSanctionEntity } from '@app-builder/models/screening'; +import { + type FamilyPersonEntity, + type FamilyRelationshipEntry, + type FamilyRelativeEntity, + type ScreeningMatch, + type ScreeningSanctionEntity, +} from '@app-builder/models/screening'; import { ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Modal } from 'ui-design-system'; @@ -31,6 +37,20 @@ const sanctionProps = [ 'sourceUrl', ] satisfies PropertyForSchema<'Sanction'>[]; +function relationshipKey({ source, value }: FamilyRelationshipEntry) { + return `${source}:${value}`; +} + +function dedupeRelationships(entries: FamilyRelationshipEntry[]) { + const seen = new Set(); + return entries.filter((entry) => { + const key = relationshipKey(entry); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + export function MatchDetails({ entity, before, highlightText }: MatchDetailsProps) { const { t } = useTranslation(screeningsI18n); const [selectedSanction, setSelectedSanction] = useState(null); @@ -39,43 +59,108 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp const deduplicatedEntity = useMemo(() => { if (entity.schema !== 'Person') return entity; - // family person & relative - const familyPersonIds = new Set(entity.properties?.familyPerson?.flatMap(({ properties }) => properties?.person)); - if (!familyPersonIds.size) return entity; - const familyRelative = entity.properties.familyRelative?.filter( - ({ properties }) => !properties.relative?.some((relativeId) => familyPersonIds.has(relativeId)), - ); - // assocations - const associateIds = new Set( - entity.properties?.associations?.flatMap(({ properties }) => properties?.person?.map((p) => p.id)), - ); - const ignored: Array<{ id: string; relationship: string[] }> = []; - const associations = entity.properties?.associations?.filter(({ properties }) => { - if (!properties?.person) return false; - if (properties.person?.some((p) => associateIds.has(p.id))) { - const personId = properties.person?.find((p) => associateIds.has(p.id))?.id; - associateIds.delete(personId); + + let familyPerson = entity.properties.familyPerson; + let familyRelative = entity.properties.familyRelative; + + if (entity.properties.familyPerson?.length) { + const familyPersonIds = new Set( + entity.properties.familyPerson.flatMap(({ properties }) => properties.person ?? []), + ); + const ignoredFamilyRelative: Array<{ relativePersonId: string; relationship: string[] }> = []; + + familyRelative = entity.properties.familyRelative?.filter(({ properties }) => { + const matchingPersonId = properties.relative?.find((relativeId) => familyPersonIds.has(relativeId)); + if (matchingPersonId) { + if (properties.relationship?.length) { + properties.person?.forEach((person) => { + ignoredFamilyRelative.push({ + relativePersonId: person.id, + relationship: properties.relationship!, + }); + }); + } + return false; + } return true; - } - properties.person.forEach((p) => ignored.push({ id: p.id, relationship: properties.relationship! })); - return false; - }); - // complete relationships of original associations with ignored relationships (only once) - ignored.forEach((person) => { - const association = entity.properties?.associations?.find(({ properties }) => - properties?.person?.some((p) => p.id === person.id), + }); + + familyPerson = entity.properties.familyPerson.map((entry) => ({ + ...entry, + properties: { + ...entry.properties, + relationships: dedupeRelationships( + (entry.properties.relationship ?? []).map((value) => ({ + value, + source: 'familyPerson' as const, + })), + ), + }, + })) as FamilyPersonEntity[]; + + ignoredFamilyRelative.forEach(({ relativePersonId, relationship }) => { + const familyPersonEntry = familyPerson?.find(({ properties }) => + properties.relative?.some((relative) => relative.id === relativePersonId), + ); + if (!familyPersonEntry) return; + + familyPersonEntry.properties.relationships = dedupeRelationships([ + ...(familyPersonEntry.properties.relationships ?? []), + ...relationship.map((value) => ({ value, source: 'familyRelative' as const })), + ]); + + const relationshipValues = new Set(familyPersonEntry.properties.relationship ?? []); + relationship.forEach((value) => relationshipValues.add(value)); + familyPersonEntry.properties.relationship = Array.from(relationshipValues); + }); + + familyRelative = familyRelative?.map((entry) => ({ + ...entry, + properties: { + ...entry.properties, + relationships: (entry.properties.relationship ?? []).map((value) => ({ + value, + source: 'familyRelative' as const, + })), + }, + })) as FamilyRelativeEntity[]; + } + + let associations = entity.properties.associations; + if (entity.properties.associations?.length) { + const associateIds = new Set( + entity.properties.associations.flatMap(({ properties }) => properties.person?.map((p) => p.id) ?? []), ); - if (association) { - const relationships = new Set(association.properties.relationship ?? []); - person.relationship.forEach((r) => relationships.add(r)); - association.properties.relationship = Array.from(relationships); - } - }); + const ignoredAssociation: Array<{ id: string; relationship: string[] }> = []; + + associations = entity.properties.associations.filter(({ properties }) => { + if (!properties.person) return false; + if (properties.person.some((p) => associateIds.has(p.id))) { + const personId = properties.person.find((p) => associateIds.has(p.id))?.id; + if (personId) associateIds.delete(personId); + return true; + } + properties.person.forEach((p) => + ignoredAssociation.push({ id: p.id, relationship: properties.relationship ?? [] }), + ); + return false; + }); + + ignoredAssociation.forEach((person) => { + const association = associations?.find(({ properties }) => properties.person?.some((p) => p.id === person.id)); + if (association) { + const relationships = new Set(association.properties.relationship ?? []); + person.relationship.forEach((r) => relationships.add(r)); + association.properties.relationship = Array.from(relationships); + } + }); + } return { ...entity, properties: { ...entity.properties, + familyPerson, familyRelative, associations, }, diff --git a/packages/app-builder/src/components/Screenings/TopicTag.tsx b/packages/app-builder/src/components/Screenings/TopicTag.tsx index f354ead3f..e451fc48a 100644 --- a/packages/app-builder/src/components/Screenings/TopicTag.tsx +++ b/packages/app-builder/src/components/Screenings/TopicTag.tsx @@ -8,10 +8,16 @@ import { import { useTranslation } from 'react-i18next'; import { Tag } from 'ui-design-system'; +export function isDisplayableTopic(topic: string): boolean { + if (topic.startsWith('filter.')) return false; + if (isLexisTopic(topic) && lexisTopicIgnoreDisplay(topic)) return false; + return true; +} + export const TopicTag = ({ topic, className }: { topic: string; className?: string }) => { const { t } = useTranslation(['screeningTopics']); - if (topic.startsWith('filter.')) { + if (!isDisplayableTopic(topic)) { return null; } @@ -24,9 +30,6 @@ export const TopicTag = ({ topic, className }: { topic: string; className?: stri } if (isLexisTopic(topic)) { - if (lexisTopicIgnoreDisplay(topic)) { - return null; - } return ( {t(`screeningTopics:lexis.${topic}`, { defaultValue: topic })} diff --git a/packages/app-builder/src/constants/screening-entity.tsx b/packages/app-builder/src/constants/screening-entity.tsx index 3a43ba2d7..4ab9dba72 100644 --- a/packages/app-builder/src/constants/screening-entity.tsx +++ b/packages/app-builder/src/constants/screening-entity.tsx @@ -1,5 +1,6 @@ import { DateBirthdateComponent, + DateDatetimeComponent, StringCodeComponent, StringCountryComponent, StringEmailComponent, @@ -8,9 +9,36 @@ import { import { ExternalLink } from '@app-builder/components/ExternalLink'; import { HighlightText } from '@app-builder/components/Screenings/HighlightText'; import { type OpenSanctionEntitySchema } from '@app-builder/models/screening'; +import { Fragment } from 'react'; import { match } from 'ts-pattern'; +import { cn, getCountryByName } from 'ui-design-system'; import { Icon } from 'ui-icons'; +const EMBEDDED_ENGLISH_DATE_REGEX = + /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+\d{4}\b/g; + +type TextSegment = { type: 'text'; value: string } | { type: 'date'; value: string }; + +function splitTextWithEmbeddedDates(value: string): TextSegment[] { + const segments: TextSegment[] = []; + let lastIndex = 0; + + for (const match of value.matchAll(EMBEDDED_ENGLISH_DATE_REGEX)) { + const index = match.index ?? 0; + if (index > lastIndex) { + segments.push({ type: 'text', value: value.slice(lastIndex, index) }); + } + segments.push({ type: 'date', value: match[0] }); + lastIndex = index + match[0].length; + } + + if (lastIndex < value.length) { + segments.push({ type: 'text', value: value.slice(lastIndex) }); + } + + return segments; +} + export type PropertyDataType = 'string' | 'country' | 'url' | 'date' | 'wikidataId'; export type PropertyForSchema< Schema extends keyof typeof schemaInheritence, @@ -270,7 +298,7 @@ export function createPropertyTransformer(ctx: { language: string; formatLanguag .split('\n') .map((v, index) => v ? ( - +
    {formatedValue(format, v, ctx.highlightText)}
    ) : (
    ), @@ -304,23 +332,67 @@ function formatedValue(format: PropertyFormat | undefined, value: string, highli .with('position', () => {value}) .with('email', () => StringEmailComponent({ value })) .with('phone', () => StringPhoneComponent({ value })) - .with(undefined, () => ) + .with(undefined, () => ) .exhaustive(); } +function TextWithEmbeddedDates({ value, highlightText }: { value: string; highlightText?: string }) { + const segments = splitTextWithEmbeddedDates(value); + + if (segments.length === 0) { + return ; + } + + return ( + <> + {segments.map((segment, index) => + segment.type === 'date' ? ( + + {DateDatetimeComponent({ value: segment.value, withTime: false, monospaced: true })} + + ) : segment.value.length > 0 ? ( + + ) : null, + )} + + ); +} + function ParseAddress({ address }: { address: string }) { const addressParts = address.split(','); return ( -
  • - - {addressParts.map((part, index) => ( -
    - {part} - {index < addressParts.length - 1 ? ( - - ) : null} -
    - ))} +
  • + + {addressParts.map((part, index) => { + const trimmedPart = part.trim(); + const isLastPart = index === addressParts.length - 1; + const country = isLastPart ? getCountryByName(trimmedPart) : undefined; + + return ( +
    + {country ? ( + StringCountryComponent({ value: country.isoAlpha2, withCountryName: true }) + ) : ( + {trimmedPart} + )} + {index < addressParts.length - 1 ? : null} +
    + ); + })}
  • ); } + +export function IconDot({ dark, spaced }: { dark?: boolean; spaced?: boolean }) { + return ( + + ); +} diff --git a/packages/app-builder/src/models/screening.ts b/packages/app-builder/src/models/screening.ts index 0f26ca14b..9fe7467a2 100644 --- a/packages/app-builder/src/models/screening.ts +++ b/packages/app-builder/src/models/screening.ts @@ -93,6 +93,13 @@ export type PersonEntity = OpenSanctionEntity & { } & Record; }; +export type FamilyRelationshipSource = 'familyPerson' | 'familyRelative'; + +export type FamilyRelationshipEntry = { + value: string; + source: FamilyRelationshipSource; +}; + export type FamilyPersonEntity = OpenSanctionEntity & { schema: 'Family'; properties: { @@ -102,6 +109,7 @@ export type FamilyPersonEntity = OpenSanctionEntity & { sourceUrl?: string[]; startDate?: string[]; relationship?: string[]; + relationships?: FamilyRelationshipEntry[]; } & Record; }; @@ -114,6 +122,7 @@ export type FamilyRelativeEntity = OpenSanctionEntity & { sourceUrl?: string[]; startDate?: string[]; relationship?: string[]; + relationships?: FamilyRelationshipEntry[]; } & Record; }; diff --git a/packages/ui-design-system/src/ExpandableGroupTagLine/ExpandableGroupTagLine.spec.tsx b/packages/ui-design-system/src/ExpandableGroupTagLine/ExpandableGroupTagLine.spec.tsx index 8dbf7c477..863b08521 100644 --- a/packages/ui-design-system/src/ExpandableGroupTagLine/ExpandableGroupTagLine.spec.tsx +++ b/packages/ui-design-system/src/ExpandableGroupTagLine/ExpandableGroupTagLine.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { mockResizeObserver } from 'jsdom-testing-mocks'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Tag } from '../Tag/Tag'; import { ExpandableGroupTagLine } from './ExpandableGroupTagLine'; @@ -18,35 +18,56 @@ function makeItems(count = labels.length) { )); } +function mockLayoutWidths({ containerWidth, childWidth }: { containerWidth: number; childWidth: number }) { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + if (this.classList.contains('relative') && this.classList.contains('min-w-0')) { + return containerWidth; + } + return childWidth; + }); + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { + return childWidth; + }); +} + describe('ExpandableGroupTagLine', () => { - // In jsdom every element reports an offsetWidth of 0, so the overflow - // calculation keeps a single visible item and collapses the rest behind the - // "more" button. This lets us exercise the expand/collapse behaviour. + afterEach(() => { + vi.restoreAllMocks(); + }); it('should render successfully', () => { render(); - // Every item is also mirrored in the (aria-hidden) ghost row used for - // measuring, so each label is present at least once. + for (const label of labels) { + expect(screen.getAllByText(label).length).toBeGreaterThan(0); + } + }); + + it('should show all items when container width is not yet measurable', () => { + render(); + + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); for (const label of labels) { expect(screen.getAllByText(label).length).toBeGreaterThan(0); } }); it('should show the default overflow "more" button when items do not fit', () => { + mockLayoutWidths({ containerWidth: 100, childWidth: 50 }); render(); - // 4 items, 1 visible => overflow of 3. expect(screen.getByText('+3')).toBeInTheDocument(); }); it('should not render the "more" button when a single item fits', () => { + mockLayoutWidths({ containerWidth: 400, childWidth: 50 }); render(); expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); }); it('should expand to reveal all items and hide the "more" button on click', async () => { + mockLayoutWidths({ containerWidth: 100, childWidth: 50 }); const user = userEvent.setup(); render(); @@ -56,6 +77,7 @@ describe('ExpandableGroupTagLine', () => { }); it('should collapse back when the "less" button is clicked', async () => { + mockLayoutWidths({ containerWidth: 100, childWidth: 50 }); const user = userEvent.setup(); render( { }); it('should use a custom "more" button when provided', () => { + mockLayoutWidths({ containerWidth: 100, childWidth: 50 }); render( (null); const ghostRef = useRef(null); const [maxVisible, setMaxVisible] = useState(items.length); + const [renderedCount, setRenderedCount] = useState(items.length); useIsomorphicLayoutEffect(() => { if (isExpanded) return; @@ -53,14 +59,16 @@ export function ExpandableGroupTagLine({ if (!container || !ghost) return; const recalculate = () => { - const gap = parseFloat(getComputedStyle(ghost).gap) || 4; const availableWidth = container.offsetWidth; + if (availableWidth === 0) return; + + const gap = parseFloat(getComputedStyle(ghost).gap) || 4; const tagEls = Array.from(ghost.children) as HTMLElement[]; let used = 0; let count = 0; for (let i = 0; i < tagEls.length; i++) { - const tw = tagEls[i]!.offsetWidth; + const tw = getElementWidth(tagEls[i]!); const gapBefore = i > 0 ? gap : 0; const isLast = i === tagEls.length - 1; const needed = used + gapBefore + tw + (isLast ? 0 : gap + overflowTagWidth); @@ -71,6 +79,8 @@ export function ExpandableGroupTagLine({ break; } } + + setRenderedCount(tagEls.length); setMaxVisible(Math.max(count, 1)); }; @@ -80,7 +90,7 @@ export function ExpandableGroupTagLine({ return () => observer.disconnect(); }, [isExpanded, items.length, overflowTagWidth]); - const overflow = isExpanded ? 0 : Math.max(0, items.length - maxVisible); + const overflow = isExpanded ? 0 : Math.max(0, renderedCount - maxVisible); const visibleItems = overflow > 0 ? items.slice(0, maxVisible) : items; const handleExpand = (event: MouseEvent) => { @@ -97,7 +107,7 @@ export function ExpandableGroupTagLine({
    *]:shrink-0', classname, )} aria-hidden="true" @@ -105,7 +115,11 @@ export function ExpandableGroupTagLine({ {items}
    *]:shrink-0', + isExpanded ? 'flex-wrap' : 'overflow-hidden', + classname, + )} > {visibleItems} {overflow > 0 && diff --git a/packages/ui-design-system/src/SelectCountry/SelectCountry.tsx b/packages/ui-design-system/src/SelectCountry/SelectCountry.tsx index ac42c22fa..119a8db8c 100644 --- a/packages/ui-design-system/src/SelectCountry/SelectCountry.tsx +++ b/packages/ui-design-system/src/SelectCountry/SelectCountry.tsx @@ -245,3 +245,9 @@ export function CountryFlagItem({ country, selected }: { country: CountryFlag; s ); } + +export function getCountryByName(name: string): CountryFlag | undefined { + return Object.values(allCountryFlags as Record).find( + (c) => c.nameEnglish.toLowerCase() === name.toLowerCase(), + ); +} From 2fa9d96bcdfc585828e2e4c93776694009f81af5 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Fri, 12 Jun 2026 10:35:30 +0200 Subject: [PATCH 11/23] modal for associates and family members details --- .../FreeformSearch/FreeformMatchCard.tsx | 15 ++++++++-- .../Screenings/MatchCard/Associations.tsx | 8 +++-- .../Screenings/MatchCard/FamilyDetail.tsx | 16 ++++++++-- .../Screenings/MatchCard/ModalPerson.tsx | 30 +++++++++++++++++++ .../src/constants/screening-entity.tsx | 5 ++++ packages/ui-design-system/src/Modal/Modal.tsx | 6 +++- 6 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx index 4f054933a..f3f93cd17 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx @@ -56,7 +56,7 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM
    ) : null} - +
    @@ -65,7 +65,17 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM export default FreeformMatchCard; -function DataContent({ entityId, searchTerm, isOpen }: { entityId: string; searchTerm?: string; isOpen: boolean }) { +export function FreeFormMatchCardDataContent({ + entityId, + searchTerm, + isOpen, + withTopics = false, +}: { + entityId: string; + searchTerm?: string; + isOpen: boolean; + withTopics?: boolean; +}) { const { t } = useTranslation(screeningsI18n); const enrichedData = useGetEnrichedDataQuery({ entityId }, isOpen); if (enrichedData.isLoading) return ; @@ -74,6 +84,7 @@ function DataContent({ entityId, searchTerm, isOpen }: { entityId: string; searc if (!entity) return
    {t('screenings:match.enriched_data_error')}
    ; return (
    + {withTopics && }
    ); diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx index 7f2adc86e..a40045c7d 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx @@ -7,6 +7,7 @@ import * as R from 'remeda'; import { ExpandableGroupTagLine } from 'ui-design-system'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; +import ModalPerson from './ModalPerson'; const MAX_ASSOCIATIONS = 5; @@ -71,7 +72,7 @@ export const Associations = ({ associations }: { associations: AssociationEntity ) : ( - {properties.alias?.[0] ?? properties.name?.[0]} + {properties.name?.[0] ?? properties.alias?.[0]} ), , @@ -97,7 +98,10 @@ export const Associations = ({ associations }: { associations: AssociationEntity {isFirstElement &&
    {t('screenings:match.associations.title')}
    }
    - +
    + + +
    {association.properties.sourceUrl && association.properties.sourceUrl.length > 0 && ( diff --git a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx index 17674c19a..763dc91ec 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx @@ -12,6 +12,7 @@ import { cn, ExpandableGroupTagLine } from 'ui-design-system'; import { Icon } from 'ui-icons'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; +import ModalPerson from './ModalPerson'; const MAX_FAMILY_MEMBERS = 5; @@ -76,6 +77,15 @@ function flattenFamilyMembers( return rows; } +function getPersonName(entity: FamilyPersonEntity | FamilyRelativeEntity) { + const { firstName, lastName, name, alias } = entity.properties; + if (firstName?.[0] && lastName?.[0]) return `${firstName[0]} ${lastName[0]}`.trim(); + if (name?.[0]) return name[0]; + if (alias?.[0]) return alias[0]; + + return '?'; +} + export function FamilyDetail({ familyMembers, relation }: FamilyDetailProps) { const { t } = useTranslation(['screenings', 'common']); const [showAll, setShowAll] = useState(false); @@ -122,8 +132,10 @@ export function FamilyDetail({ familyMembers, relation } {isFirstElement &&
    {t('screenings:match.family-members.title')}
    }
    - - +
    + + +
    {member.properties.sourceUrl && member.properties.sourceUrl.length > 0 && (
    {t('screenings:match.family.source.label')}
    diff --git a/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx b/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx new file mode 100644 index 000000000..7a5449ea4 --- /dev/null +++ b/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from 'ui-design-system'; +import { Icon } from 'ui-icons'; +import { FreeFormMatchCardDataContent } from '../FreeformSearch/FreeformMatchCard'; + +export default function ModalPerson({ personId, personName }: { personId: string; personName: string }) { + const { t } = useTranslation(['common']); + return ( + + + + + + {personName} + + + + + + + + + + + ); +} diff --git a/packages/app-builder/src/constants/screening-entity.tsx b/packages/app-builder/src/constants/screening-entity.tsx index 4ab9dba72..5011a4175 100644 --- a/packages/app-builder/src/constants/screening-entity.tsx +++ b/packages/app-builder/src/constants/screening-entity.tsx @@ -270,6 +270,7 @@ const propertyMetadata: Record = { listingDate: { type: 'string', format: 'dateTime' }, }; +// list of properties that are displayed in a list, not inline export const propertyMetadataList: Array = ['address']; export function getSanctionEntityProperties(schema: OpenSanctionEntitySchema) { @@ -321,6 +322,7 @@ export function createPropertyTransformer(ctx: { language: string; formatLanguag }; } +// format values using the components of the data field component function formatedValue(format: PropertyFormat | undefined, value: string, highlightText?: string) { return match(format) .with('monospace', () => StringCodeComponent({ value })) @@ -336,6 +338,8 @@ function formatedValue(format: PropertyFormat | undefined, value: string, highli .exhaustive(); } +// highlight the dates and show them in the locale formet +// not sure if this one is really relevant function TextWithEmbeddedDates({ value, highlightText }: { value: string; highlightText?: string }) { const segments = splitTextWithEmbeddedDates(value); @@ -358,6 +362,7 @@ function TextWithEmbeddedDates({ value, highlightText }: { value: string; highli ); } +// try to figure out the country to display it with the fancy flag function ParseAddress({ address }: { address: string }) { const addressParts = address.split(','); return ( diff --git a/packages/ui-design-system/src/Modal/Modal.tsx b/packages/ui-design-system/src/Modal/Modal.tsx index c1382ae8c..16b270888 100644 --- a/packages/ui-design-system/src/Modal/Modal.tsx +++ b/packages/ui-design-system/src/Modal/Modal.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx'; import { forwardRef, type ReactNode } from 'react'; const modalContentClassnames = cva( - 'bg-surface-card top-[10vh] flex w-full flex-col rounded-lg drop-shadow-xl overflow-hidden', + 'bg-surface-card top-[10vh] flex w-full flex-col rounded-lg drop-shadow-xl overflow-x-hidden overflow-y-auto max-h-[80vh]', { variants: { size: { @@ -12,12 +12,16 @@ const modalContentClassnames = cva( medium: 'max-w-2xl', large: 'max-w-5xl', xlarge: 'max-w-7xl', + full: 'max-w-[90vw]', }, fixedHeight: { true: null, false: 'h-fit', }, }, + defaultVariants: { + size: 'full', + }, }, ); From f37f63780243ea8397388fdadf9d8c1606b14da4 Mon Sep 17 00:00:00 2001 From: Pascal <128643171+Pascal-Delange@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:09:22 +0200 Subject: [PATCH 12/23] Pascal/save and read freeform search (#1638) * openapi for the new results save & get endpoints * add missing rows in core api yaml * rework dtos * openapi clarification * openapi clarification * last fix on filter dto for manual searches --- .../src/repositories/ScreeningRepository.ts | 2 +- .../marble-api/openapis/marblecore-api.yaml | 4 + .../openapis/marblecore-api/screenings.yml | 73 ++++++++++++++++--- .../src/generated/marblecore-api.ts | 55 ++++++++++++-- 4 files changed, 116 insertions(+), 18 deletions(-) diff --git a/packages/app-builder/src/repositories/ScreeningRepository.ts b/packages/app-builder/src/repositories/ScreeningRepository.ts index aae007adf..1812cb2e4 100644 --- a/packages/app-builder/src/repositories/ScreeningRepository.ts +++ b/packages/app-builder/src/repositories/ScreeningRepository.ts @@ -155,7 +155,7 @@ export function makeGetScreeningRepository() { const { id, matches } = await marbleCoreApiClient.freeformSearch(dto, { limit }); return { id, - matches: R.map(matches, (match) => adaptScreeningMatchPayload(match.payload)), + matches: R.map(matches, (match) => adaptScreeningMatchPayload(match)), }; }, getAiSuggestions: async ({ screeningId }) => { diff --git a/packages/marble-api/openapis/marblecore-api.yaml b/packages/marble-api/openapis/marblecore-api.yaml index 318302254..484523508 100644 --- a/packages/marble-api/openapis/marblecore-api.yaml +++ b/packages/marble-api/openapis/marblecore-api.yaml @@ -243,6 +243,10 @@ paths: $ref: ./marblecore-api/screenings.yml#/~1screenings~1refine /screenings/freeform-search: $ref: ./marblecore-api/screenings.yml#/~1screenings~1freeform-search + /screenings/freeform-search/{id}: + $ref: ./marblecore-api/screenings.yml#/~1screenings~1freeform-search~1{id} + /screenings/freeform-search/{id}/save: + $ref: ./marblecore-api/screenings.yml#/~1screenings~1freeform-search~1{id}~1save /screenings/freshness: $ref: ./marblecore-api/screenings.yml#/~1screenings~1freshness /screenings/entities/{entityId}: diff --git a/packages/marble-api/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 1f4db19aa..279ca64d1 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -368,14 +368,6 @@ - $ref: 'components.yml#/parameters/limit' - $ref: 'components.yml#/parameters/offset_id' - $ref: 'components.yml#/parameters/order' - - name: sorting - description: the field used to sort the items - in: query - required: false - schema: - type: string - enum: - - created_at - name: user_id description: filter searches by user ID in: query @@ -390,8 +382,8 @@ schema: type: string format: uuid - - name: is_saved - description: show only searches that have been manually saved + - name: saved_only + description: If true, only return searches for which results were saved in: query required: false schema: @@ -417,6 +409,58 @@ $ref: 'components.yml#/responses/401' '403': $ref: 'components.yml#/responses/403' +/screenings/freeform-search/{id}/save: + post: + tags: + - Screening + summary: Manually save a freeform search and its results. Idempotent if called multiple times. Returns a 409 if the set of entities returned by the search has changed since the initial search was done. + operationId: saveFreeformSearch + parameters: + - name: id + description: ID of the freeform search to save + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: empty body on save + '401': + $ref: 'components.yml#/responses/401' + '403': + $ref: 'components.yml#/responses/403' + '404': + $ref: 'components.yml#/responses/404' + '409': + $ref: 'components.yml#/responses/409' +/screenings/freeform-search/{id}: + get: + tags: + - Screening + summary: Get a freeform search and its results + operationId: getFreeformSearch + parameters: + - name: id + description: ID of the freeform search to retrieve + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: The freeform search and its matches + content: + application/json: + schema: + $ref: '#/components/schemas/ScreeningFreeformSearchDto' + '401': + $ref: 'components.yml#/responses/401' + '403': + $ref: 'components.yml#/responses/403' + '404': + $ref: 'components.yml#/responses/404' components: schemas: @@ -1000,7 +1044,7 @@ components: matches: type: array items: - $ref: '#/components/schemas/ScreeningMatchDto' + $ref: '#/components/schemas/ScreeningMatchPayloadDto' ScreeningFreeformSearchDto: type: object required: @@ -1008,6 +1052,7 @@ components: - created_at - search_input - search_config + - is_saved properties: id: type: string @@ -1025,6 +1070,12 @@ components: $ref: '#/components/schemas/ScreeningFreeformSearchInputDto' search_config: $ref: '#/components/schemas/ScreeningFreeformSearchConfigDto' + is_saved: + type: boolean + matches: + type: array + items: + $ref: '#/components/schemas/ScreeningMatchDto' ScreeningFreeformSearchInputDto: type: object required: diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index 455babfe8..1f26bc7e1 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -1164,6 +1164,8 @@ export type ScreeningFreeformSearchDto = { }; }; search_config: ScreeningFreeformSearchConfigDto; + is_saved: boolean; + matches?: ScreeningMatchDto[]; }; export type OpenSanctionsUpstreamDatasetFreshnessDto = { version: string; @@ -4169,7 +4171,7 @@ export function freeformSearch(body?: { status: 200; data: { id: string; - matches: ScreeningMatchDto[]; + matches: ScreeningMatchPayloadDto[]; }; }>(`/screenings/freeform-search${QS.query(QS.explode({ limit @@ -4182,14 +4184,13 @@ export function freeformSearch(body?: { /** * List past freeform searches */ -export function listFreeformSearches({ limit, offsetId, order, sorting, userId, apiKeyId, isSaved }: { +export function listFreeformSearches({ limit, offsetId, order, userId, apiKeyId, savedOnly }: { limit?: number; offsetId?: string; order?: "ASC" | "DESC"; - sorting?: "created_at"; userId?: string; apiKeyId?: string; - isSaved?: boolean; + savedOnly?: boolean; } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -4207,14 +4208,56 @@ export function listFreeformSearches({ limit, offsetId, order, sorting, userId, limit, offset_id: offsetId, order, - sorting, user_id: userId, api_key_id: apiKeyId, - is_saved: isSaved + saved_only: savedOnly }))}`, { ...opts })); } +/** + * Get a freeform search and its results + */ +export function getFreeformSearch(id: string, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ScreeningFreeformSearchDto; + } | { + status: 401; + data: string; + } | { + status: 403; + data: string; + } | { + status: 404; + data: string; + }>(`/screenings/freeform-search/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Manually save a freeform search and its results. Idempotent if called multiple times. Returns a 409 if the set of entities returned by the search has changed since the initial search was done. + */ +export function saveFreeformSearch(id: string, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + } | { + status: 401; + data: string; + } | { + status: 403; + data: string; + } | { + status: 404; + data: string; + } | { + status: 409; + data: string; + }>(`/screenings/freeform-search/${encodeURIComponent(id)}/save`, { + ...opts, + method: "POST" + })); +} /** * Retrieve the freshness of sanction datasets */ From 3cc040210328e6a8f18397e2a1c6df3aea43b5d8 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Fri, 12 Jun 2026 14:57:03 +0200 Subject: [PATCH 13/23] - Introduced new translations for birth date approximative age in multiple languages. - Work on ViewSavedResults to display search inputs and filter --- .../Screenings/EntityProperties.tsx | 15 +- .../FreeformSearch/ViewSavedResults.tsx | 157 +++++++++++++----- .../app-builder/src/components/Spinner.tsx | 6 +- .../src/constants/screening-entity.tsx | 136 ++++++++++++++- .../src/locales/ar/screenings.json | 4 + .../src/locales/en/screenings.json | 4 + .../src/locales/fr/screenings.json | 4 + .../src/queries/screening/freeform-search.ts | 40 +---- 8 files changed, 276 insertions(+), 90 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/EntityProperties.tsx b/packages/app-builder/src/components/Screenings/EntityProperties.tsx index c955dc02c..ee7894664 100644 --- a/packages/app-builder/src/components/Screenings/EntityProperties.tsx +++ b/packages/app-builder/src/components/Screenings/EntityProperties.tsx @@ -1,4 +1,5 @@ import { + BirthdDateAverage, createPropertyTransformer, getSanctionEntityProperties, IconDot, @@ -64,18 +65,20 @@ export function EntityProperties({ {entityPropertyList.map(({ property, values, restItemsCount }) => { return ( - +
    {t(`screenings:entity.property.${property}`, { defaultValue: property, })} - - - {values.length > 0 ? ( +
    +
    + {property === 'birthDate' ? ( + + ) : values.length > 0 ? ( {values.map((v, i) => ( - {i === values.length - 1 || property === 'address' ? null : } + {i === values.length - 1 || isPropertyListed(property) ? null : } ))} {restItemsCount > 0 ? ( @@ -96,7 +99,7 @@ export function EntityProperties({ ) : ( not available )} - +
    ); })} diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index 85bcca53a..c0814dbbd 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -1,16 +1,21 @@ import { DateRangeFilter } from '@app-builder/components/Filters'; import { PanelContainer, PanelContent, PanelFooter, PanelRoot } from '@app-builder/components/Panel/Panel'; +import { IconDot, SEARCH_ENTITIES, SearchableSchema } from '@app-builder/constants/screening-entity'; import { type SavedScreeningSearch } from '@app-builder/models/screening'; import { useSavedFreeformSearchesQuery } from '@app-builder/queries/screening/freeform-search'; +import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail'; // import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail'; import { useOrganizationUsers } from '@app-builder/services/organization/organization-users'; import { formatDateTimeWithoutPresets, formatDuration, useFormatLanguage } from '@app-builder/utils/format'; +import { ScreeningConfigBodySectionDto } from 'marble-api'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; // import { Temporal } from 'temporal-polyfill'; import { + Avatar, // Avatar, Button, + Collapsible, // Collapsible, cn, // ExpandableGroupTagLine, @@ -180,50 +185,114 @@ export const ViewSavedResults = () => { }; function SavedSearchRow({ search }: { search: SavedScreeningSearch }) { - // const { t } = useTranslation(['screenings', 'common']); - // const language = useFormatLanguage(); - // const { currentUser } = useOrganizationDetails(); - // const { getOrgUserById } = useOrganizationUsers(); - // const owner = getOrgUserById(search.ownerId); - // const isYou = currentUser.actorIdentity.userId === search.ownerId; + const { t } = useTranslation(['screenings', 'common']); + const language = useFormatLanguage(); + const { currentUser } = useOrganizationDetails(); + const { getOrgUserById } = useOrganizationUsers(); + const owner = search.user_id ? getOrgUserById(search.user_id) : undefined; + const isYou = currentUser.actorIdentity.userId === search.user_id; + + return ( + + +
    + {search.search_input.query?.['name']?.join(', ')} +
    +
    + {owner ? ( + + + + {`${owner.firstName} ${owner.lastName}`.trim()} + {isYou ? ` (${t('screenings:freeform_search.saved_results.you')})` : null} + + + ) : ( + {search.user_id} + )} + + {formatDateTimeWithoutPresets(search.created_at, { language, dateStyle: 'short' })} + + {search.is_saved ? : {t('screenings:freeform_search.saved_results.not_saved')}} +
    +
    + + + + {/*
    + {search.results.map((entity) => ( + + ))} +
    */} + {/*
    {JSON.stringify(search, null, 2)}
    */} +
    +
    + ); +} + +function FilterValues({ filter }: { filter: SavedScreeningSearch['search_config'] }) { + return ( +
    + + {filter.provider} + + + {Object.entries(filter.filters) + .filter(([, value]) => value.enabled) + .map(([key, value], index) => ( + <> + {value?.datasets?.length && ( + + {key}:{value?.datasets?.length ?? 0} + + )} + {value?.topics && } + + ))} +
    + ); +} + +function TopicTag({ topics }: { topics: NonNullable }) { + const { t } = useTranslation(['screenings']); + if (topics['livness'] && topics['livness'].length === 1) + return ( + + {t('freeform_search.global.liveness')} + + ); + return ( + <> + {Object.entries(topics).map(([key, value]) => ( + + {key}:{value?.length ?? 0} + + ))} + + ); +} + +function QueryValues({ query, type }: { query: SavedScreeningSearch['search_input']; type: SearchableSchema }) { + const { t } = useTranslation(['screenings']); + const entityType = query.type; + const entityTypeFields = + entityType && entityType in SEARCH_ENTITIES + ? SEARCH_ENTITIES[entityType].fields.filter((f: string) => f !== 'name') + : []; return ( -
    {search.id}
    - // - // - //
    - // {search.name} - // {owner ? ( - // - // - // - // {`${owner.firstName} ${owner.lastName}`.trim()} - // {isYou ? ` (${t('screenings:freeform_search.saved_results.you')})` : null} - // - // - // ) : ( - // {search.ownerId} - // )} - // - //
    - //
    - // - // {formatDateTimeWithoutPresets(search.createdAt, { language, dateStyle: 'short' })} - // - // - // - // {t('screenings:freeform_search.results_count', { count: search.results.length })} - // - //
    - //
    - // - //
    - // {search.results.map((entity) => ( - // - // ))} - //
    - //
    - //
    +
    + {type !== 'Thing' && ( + + {t(`screenings:entity.schema.${type.toLocaleLowerCase()}`)} + + )} + {entityTypeFields.map((field) => ( + + {t(`screenings:entity.property.${field}.short`)}:{query.query[field]?.join(', ')} + + ))} +
    ); } @@ -339,7 +408,7 @@ function PeriodFilter({ }} > -
    @@ -164,19 +164,25 @@ export const ViewSavedResults = () => { - { - setLimit(l); - setPage(1); - }} - rangeStart={rangeStart} - rangeEnd={rangeEnd} - hasPrev={hasPrev} - hasNext={hasNext} - onPrev={() => setPage((p) => Math.max(1, p - 1))} - onNext={() => setPage((p) => p + 1)} - /> +
    + setPaginationParams({ limit: pageSize })} + /> + + setPaginationParams((prev) => ({ + limit: prev.limit ?? 25, + ...newPaginationParams, + })) + } + paginationState={paginationState} + boundariesDisplay="dates" + hasNextPage={hasNextPage} + itemsPerPage={limit} + /> +
    @@ -213,7 +219,11 @@ function SavedSearchRow({ search }: { search: SavedScreeningSearch }) { {formatDateTimeWithoutPresets(search.created_at, { language, dateStyle: 'short' })} - {search.is_saved ? : {t('screenings:freeform_search.saved_results.not_saved')}} + {search.is_saved ? ( + {search?.matches?.length ?? 0} + ) : ( + {t('screenings:freeform_search.saved_results.not_saved')} + )} @@ -224,7 +234,7 @@ function SavedSearchRow({ search }: { search: SavedScreeningSearch }) { ))} */} - {/*
    {JSON.stringify(search, null, 2)}
    */} +
    {JSON.stringify(search, null, 2)}
    ); @@ -287,85 +297,17 @@ function QueryValues({ query, type }: { query: SavedScreeningSearch['search_inpu {t(`screenings:entity.schema.${type.toLocaleLowerCase()}`)} )} - {entityTypeFields.map((field) => ( - - {t(`screenings:entity.property.${field}.short`)}:{query.query[field]?.join(', ')} - - ))} + {entityTypeFields.map((field) => + query.query[field] ? ( + + {t(`screenings:entity.property.${field}.short`)}:{query.query[field].join(', ')} + + ) : null, + )} ); } -// const inputTagOverflowButtonClassName = 'cursor-pointer shrink-0'; - -// function InputTags({ input }: { input: SavedScreeningSearch['inputs'] }) { -// const { t } = useTranslation(['screenings']); - -// const tagItems = useMemo(() => { -// const items: ReactNode[] = []; - -// if (input.entityType) { -// items.push( -// , -// ); -// } - -// const datasets = input.datasets.filter((d) => d.indexOf(':') <= 0); -// if (datasets.length > 0) { -// items.push(); -// } - -// for (const [field, value] of Object.entries(input.fields)) { -// if (value) { -// items.push( -// , -// ); -// } -// } - -// return items; -// }, [input, t]); - -// if (tagItems.length === 0) return null; - -// return ( -// ( -// -// +{overflow} -// -// )} -// lessButton={(onCollapse) => ( -// -// -// -// )} -// /> -// ); -// } - -// function InputTag({ label, values }: { label?: string; values: string | string[] }) { -// if (values.length === 0) return null; -// return ( -// -// {label ? {label} : null} -// {Array.isArray(values) ? ( -// -// {values.slice(0, 2).join(', ')} -// {values.length > 2 ? {` +${values.length - 2}`} : null} -// -// ) : ( -// {values} -// )} -// -// ); -// } - function PeriodFilter({ value, onChange, @@ -527,62 +469,37 @@ function OwnerFilter({ ); } -function ViewSavedResultsPaginationRow({ +function SavedResultsPageSizeSelector({ limit, onLimitChange, - rangeStart, - rangeEnd, - hasPrev, - hasNext, - onPrev, - onNext, }: { limit: PageSize; onLimitChange: (limit: PageSize) => void; - rangeStart: number; - rangeEnd: number; - hasPrev: boolean; - hasNext: boolean; - onPrev: () => void; - onNext: () => void; }) { const { t } = useTranslation(['screenings']); return ( -
    -
    - - {t('screenings:freeform_search.saved_results.results_per_page')} - - {PAGE_SIZES.map((size) => { - const active = size === limit; - return ( - - ); - })} -
    -
    - - {t('screenings:freeform_search.saved_results.range', { start: rangeStart, end: rangeEnd })} - - - -
    +
    + + {t('screenings:freeform_search.saved_results.results_per_page')} + + {PAGE_SIZES.map((size) => { + const active = size === limit; + return ( + + ); + })}
    ); } diff --git a/packages/app-builder/src/queries/screening/freeform-search.ts b/packages/app-builder/src/queries/screening/freeform-search.ts index 1312a7045..6a9157d33 100644 --- a/packages/app-builder/src/queries/screening/freeform-search.ts +++ b/packages/app-builder/src/queries/screening/freeform-search.ts @@ -7,6 +7,7 @@ import { type FreeformSearchInput, freeformSearchFn, listSavedFreeformSearchesFn, + saveFreeformSearchFn, } from '@app-builder/server-fns/screenings'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useServerFn } from '@tanstack/react-start'; @@ -38,6 +39,23 @@ export const useFreeformSearchMutation = () => { }); }; +type SaveFreeformSearchResponse = { success: true } | { success: false; error: unknown }; + +export const useSaveFreeformSearchMutation = () => { + const queryClient = useQueryClient(); + const saveFreeformSearch = useServerFn(saveFreeformSearchFn); + + return useMutation({ + mutationKey: ['screening', 'save-freeform-search'], + mutationFn: async (input: { id: string }): Promise => { + return saveFreeformSearch({ data: input }); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); + }, + }); +}; + type ListSavedFreeformSearchesResponse = | { success: true; data: SavedScreeningSearchPage } | { success: false; error: unknown }; diff --git a/packages/app-builder/src/repositories/ScreeningRepository.ts b/packages/app-builder/src/repositories/ScreeningRepository.ts index 1812cb2e4..dc5299d28 100644 --- a/packages/app-builder/src/repositories/ScreeningRepository.ts +++ b/packages/app-builder/src/repositories/ScreeningRepository.ts @@ -53,6 +53,7 @@ export interface ScreeningRepository { threshold?: number; limit?: number; }): Promise<{ id: string; matches: ScreeningMatchPayload[] }>; + saveFreeformSearch(args: { id: string }): Promise; getAiSuggestions(args: { screeningId: string }): Promise; enrichedData(args: { entityId: string }): Promise; getAvailableFilters(args: { feature: AvailableFeatures }): Promise; @@ -158,11 +159,21 @@ export function makeGetScreeningRepository() { matches: R.map(matches, (match) => adaptScreeningMatchPayload(match)), }; }, + saveFreeformSearch: async ({ id }) => { + await marbleCoreApiClient.saveFreeformSearch(id); + }, getAiSuggestions: async ({ screeningId }) => { return R.map(await marbleCoreApiClient.getScreeningAiSuggestions(screeningId), adaptScreeningAiSuggestion); }, - listSavedScreeningSearches: async (filters) => { - return await marbleCoreApiClient.listFreeformSearches(filters); + listSavedScreeningSearches: async ({ isSaved, userId, apiKeyId, offsetId, limit, order }) => { + return await marbleCoreApiClient.listFreeformSearches({ + savedOnly: isSaved, + userId, + apiKeyId, + offsetId, + limit, + order, + }); }, }); } diff --git a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx index 3282836f8..40e449215 100644 --- a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx +++ b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx @@ -8,10 +8,9 @@ import { PrintResults, PrintSearchSummary, } from '@app-builder/components/Screenings/FreeformSearch/FreeformSearchPrint'; -// TODO: Uncomment when the save search and view saved results are implemented -// import { SaveSearch } from '@app-builder/components/Screenings/FreeformSearch/SaveSearch'; import { ViewSavedResults } from '@app-builder/components/Screenings/FreeformSearch/ViewSavedResults'; import { authMiddleware } from '@app-builder/middlewares/auth-middleware'; +import { useSaveFreeformSearchMutation } from '@app-builder/queries/screening/freeform-search'; import { normalizeListConfig } from '@app-builder/queries/screening/lists-config'; import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail'; import * as Sentry from '@sentry/react'; @@ -19,6 +18,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import { type Namespace } from 'i18next'; import { useCallback, useState } from 'react'; +import toast from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; import { Button } from 'ui-design-system'; import { Icon } from 'ui-icons'; @@ -47,13 +47,21 @@ function ScreeningSearchIndexPage() { const { currentUser } = useOrganizationDetails(); const { listConfig } = Route.useLoaderData(); const [searchState, setSearchState] = useState(null); - + const saveSearchMutation = useSaveFreeformSearchMutation(); const userName = [currentUser.actorIdentity.firstName, currentUser.actorIdentity.lastName].filter(Boolean).join(' '); const handleSearchComplete = useCallback((state: FreeformSearchState) => { setSearchState(state); }, []); + function handleSaveSearch() { + if (!searchState?.inputs) return; + saveSearchMutation + .mutateAsync({ id: searchState.searchId }) + .then(() => toast.success(t('screenings:freeform_search.save.success'))) + .catch(() => toast.error(t('common:errors.unknown'))); + } + const hasResults = searchState !== null && searchState.results.length > 0; return ( @@ -78,7 +86,12 @@ function ScreeningSearchIndexPage() { - {/* */} + {searchState.searchId && ( + + )} )} diff --git a/packages/app-builder/src/server-fns/screenings.ts b/packages/app-builder/src/server-fns/screenings.ts index a9190924b..9d2f32ac4 100644 --- a/packages/app-builder/src/server-fns/screenings.ts +++ b/packages/app-builder/src/server-fns/screenings.ts @@ -1,11 +1,6 @@ import { authMiddleware } from '@app-builder/middlewares/auth-middleware'; import { isStatusConflictHttpError } from '@app-builder/models/http-errors'; -import { - availableFeatures, - type SavedScreeningSearchPage, - type Screening, - type ScreeningMatchPayload, -} from '@app-builder/models/screening'; +import { availableFeatures, type Screening, type ScreeningMatchPayload } from '@app-builder/models/screening'; import { type ScreeningAiSuggestion } from '@app-builder/models/screening-ai-suggestion'; import { getServerEnv } from '@app-builder/utils/environment'; import { getScreeningFileUploadEndpoint } from '@app-builder/utils/files'; @@ -111,9 +106,15 @@ export const savedSearchFiltersSchema = z.object({ fromDate: z.string().optional(), toDate: z.string().optional(), name: z.string().optional(), - ownerId: z.string().optional(), - limit: z.number().min(1).max(100).optional(), - page: z.number().min(1).optional(), + offsetId: z.string().optional(), + next: z.coerce.boolean().optional(), + previous: z.coerce.boolean().optional(), + limit: z.coerce.number().min(1).max(100).optional(), + order: z.enum(['ASC', 'DESC']).optional(), + sorting: z.enum(['created_at']).optional(), + userId: z.string().optional(), + apiKeyId: z.string().optional(), + isSaved: z.boolean().optional(), }); export type SavedSearchFiltersInput = z.infer; @@ -194,18 +195,31 @@ export const freeformSearchFn = createServerFn({ method: 'POST' }) try { const result = await context.authInfo.screening.freeformSearch(data); return { success: true as const, data: result }; - } catch { + } catch (error) { + console.error(`Freeform search error (${data})`, error); return { success: false as const, error: 'Freeform search failed' }; } }); +export const saveFreeformSearchFn = createServerFn({ method: 'POST' }) + .middleware([authMiddleware]) + .inputValidator(z.object({ id: z.uuid() })) + .handler(async ({ context, data }) => { + try { + await context.authInfo.screening.saveFreeformSearch(data); + return { success: true as const }; + } catch { + return { success: false as const, error: 'Save freeform search failed' }; + } + }); + export const listSavedFreeformSearchesFn = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .inputValidator(savedSearchFiltersSchema) .handler(async ({ context, data }) => { try { const page = await context.authInfo.screening.listSavedScreeningSearches(data); - return { success: true as const, data: page as SavedScreeningSearchPage }; + return { success: true as const, data: page }; } catch { return { success: false as const, error: 'List saved searches failed' }; } @@ -217,7 +231,7 @@ export const getEnrichedDataFn = createServerFn({ method: 'GET' }) .handler(async ({ context, data }) => { try { const result = await context.authInfo.screening.enrichedData(data); - return { success: true as const, data: result as ScreeningMatchPayload }; + return { success: true as const, data: result }; } catch { return { success: false as const, error: 'Enriched data failed' }; } From c519a9c66f08be6389df3dbc5663f38a3f165470 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Fri, 12 Jun 2026 18:32:39 +0200 Subject: [PATCH 15/23] display saved results cards truncate urls in the cards to improve readability --- .../FreeformSearch/FreeformMatchCard.tsx | 14 ++-- .../FreeformSearch/ViewSavedResults.tsx | 24 +++++-- .../src/constants/screening-entity.tsx | 71 ++++++++++++++++++- .../src/queries/screening/freeform-search.ts | 15 ++++ .../src/repositories/ScreeningRepository.ts | 8 +++ .../app-builder/src/server-fns/screenings.ts | 12 ++++ .../openapis/marblecore-api/screenings.yml | 2 +- .../src/generated/marblecore-api.ts | 2 +- 8 files changed, 132 insertions(+), 16 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx index f3f93cd17..f4c9eea06 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx @@ -3,7 +3,7 @@ import { type ScreeningMatchPayload } from '@app-builder/models/screening'; import { useGetEnrichedDataQuery } from '@app-builder/queries/screening/get-enriched-data'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Collapsible, Tag } from 'ui-design-system'; +import { Collapsible, cn, Tag } from 'ui-design-system'; import { MatchDetails } from '../MatchDetails'; import { screeningsI18n } from '../screenings-i18n'; import { TopicsDisplay } from '../TopicsDisplay'; @@ -12,9 +12,10 @@ interface FreeformMatchCardProps { entity: ScreeningMatchPayload; defaultOpen?: boolean; searchTerm?: string; + background?: 'card' | 'grey'; } -export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformMatchCardProps) { +export function FreeformMatchCard({ entity, defaultOpen, searchTerm, background }: FreeformMatchCardProps) { const { t } = useTranslation(screeningsI18n); const [isOpen, setIsOpen] = useState(defaultOpen ?? false); @@ -22,7 +23,10 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM return ( - +
    {entity.caption} @@ -40,7 +44,9 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM
    - +
    {entitySchema === 'person' && entity.datasets?.length ? (
    diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index 622eaab60..43b4731fb 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -4,7 +4,10 @@ import { PanelContainer, PanelContent, PanelFooter, PanelRoot } from '@app-build import { IconDot, SEARCH_ENTITIES, SearchableSchema } from '@app-builder/constants/screening-entity'; import { type PaginationParams } from '@app-builder/models/pagination'; import { type SavedScreeningSearch } from '@app-builder/models/screening'; -import { useSavedFreeformSearchesQuery } from '@app-builder/queries/screening/freeform-search'; +import { + useGetFreeformSearchQuery, + useSavedFreeformSearchesQuery, +} from '@app-builder/queries/screening/freeform-search'; import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail'; import { useOrganizationUsers } from '@app-builder/services/organization/organization-users'; import { formatDateTimeWithoutPresets, formatDuration, useFormatLanguage } from '@app-builder/utils/format'; @@ -15,6 +18,7 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Avatar, Button, Collapsible, cn, Input, MenuCommand, Separator, Tag } from 'ui-design-system'; import { Icon } from 'ui-icons'; +import FreeformMatchCard from './FreeformMatchCard'; interface StaticDateRangeFilter { type: 'static'; @@ -229,12 +233,7 @@ function SavedSearchRow({ search }: { search: SavedScreeningSearch }) { - {/*
    - {search.results.map((entity) => ( - - ))} -
    */} -
    {JSON.stringify(search, null, 2)}
    +
    ); @@ -533,3 +532,14 @@ function FilterPill({ ); } + +function SavedResults({ id }: { id: string }) { + const query = useGetFreeformSearchQuery(id); + return query.data?.success ? ( +
    + {query.data.data.matches?.map((match) => { + return ; + })} +
    + ) : null; +} diff --git a/packages/app-builder/src/constants/screening-entity.tsx b/packages/app-builder/src/constants/screening-entity.tsx index 6aa42c0fb..271f1e103 100644 --- a/packages/app-builder/src/constants/screening-entity.tsx +++ b/packages/app-builder/src/constants/screening-entity.tsx @@ -13,6 +13,7 @@ import { FormatContext } from '@app-builder/contexts/FormatContext'; import { type OpenSanctionEntitySchema } from '@app-builder/models/screening'; import { getDateFnsLocale } from '@app-builder/services/i18n/i18n-config'; import { formatDateTimeWithoutPresets, useFormatLanguage } from '@app-builder/utils/format'; +import { tryCatch } from '@app-builder/utils/tryCatch'; import { formatDuration as dateFnsFormatDuration } from 'date-fns/formatDuration'; import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; @@ -314,21 +315,85 @@ export function createPropertyTransformer(ctx: { language: string; formatLanguag : formatedValue(format, value, ctx.highlightText); case 'url': return ( - - {value} + + {cleanUrl(value)} ); case 'wikidataId': return ( - {value} + {cleanUrl(value)} ); } }; } +const MAX_DISPLAY_PATH_SEGMENT_LENGTH = 20; + +export function cleanUrl(url: string) { + const parsed = tryCatch(() => new URL(url)); + if (!parsed.ok) return url; + + const { origin, hostname, pathname } = parsed.value; + + const cleanedPathname = match(hostname) + .when( + (host) => host === 'web.archive.org', + () => { + const archiveMatch = pathname.match(/^\/web\/(\d{14})/); + if (!archiveMatch) return trimLongTrailingPathSegments(pathname); + + const kept = `/web/${archiveMatch[1]}`; + const removedSegments = pathname + .slice(kept.length) + .split('/') + .filter((segment) => segment.length > 0); + const lastRemoved = removedSegments.at(-1); + + if (!lastRemoved) return kept; + + return `${kept}/${segmentPreview(lastRemoved)}...`; + }, + ) + .otherwise(() => trimLongTrailingPathSegments(pathname)); + + return decodeUrlForDisplay(formatPathForDisplay(origin + cleanedPathname)); +} + +function trimLongTrailingPathSegments(pathname: string): string { + const segments = pathname.split('/').filter((segment) => segment.length > 0); + let lastRemoved: string | undefined; + + while (segments.length > 0 && (segments.at(-1)?.length ?? 0) > MAX_DISPLAY_PATH_SEGMENT_LENGTH) { + lastRemoved = segments.pop(); + } + + if (!lastRemoved) { + return segments.length > 0 ? `/${segments.join('/')}` : '/'; + } + + const base = segments.length > 0 ? `/${segments.join('/')}` : ''; + return `${base}/${segmentPreview(lastRemoved)}...`; +} + +function segmentPreview(segment: string): string { + const decoded = tryCatch(() => decodeURIComponent(segment)); + const value = decoded.ok ? decoded.value : segment; + return value.slice(0, MAX_DISPLAY_PATH_SEGMENT_LENGTH); +} + +function formatPathForDisplay(pathname: string): string { + if (pathname === '/') return pathname; + return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +function decodeUrlForDisplay(url: string): string { + const decoded = tryCatch(() => decodeURI(url)); + return decoded.ok ? decoded.value : url; +} + // format values using the components of the data field component function formatedValue(format: PropertyFormat | undefined, value: string, highlightText?: string) { const { locale } = FormatContext.useValue(); diff --git a/packages/app-builder/src/queries/screening/freeform-search.ts b/packages/app-builder/src/queries/screening/freeform-search.ts index 6a9157d33..3973f6894 100644 --- a/packages/app-builder/src/queries/screening/freeform-search.ts +++ b/packages/app-builder/src/queries/screening/freeform-search.ts @@ -6,6 +6,7 @@ import { import { type FreeformSearchInput, freeformSearchFn, + getFreeformSearchFn, listSavedFreeformSearchesFn, saveFreeformSearchFn, } from '@app-builder/server-fns/screenings'; @@ -70,3 +71,17 @@ export const useSavedFreeformSearchesQuery = (filters: SavedScreeningSearchFilte }, }); }; + +type GetFreeformSearchResponse = + | { success: true; data: { id: string; matches: ScreeningMatchPayload[] } } + | { success: false; error: unknown }; + +export const useGetFreeformSearchQuery = (id: string) => { + const getFreeformSearch = useServerFn(getFreeformSearchFn); + return useQuery({ + queryKey: ['screening', 'freeform-search', id], + queryFn: async (): Promise => { + return getFreeformSearch({ data: { id } }) as Promise; + }, + }); +}; diff --git a/packages/app-builder/src/repositories/ScreeningRepository.ts b/packages/app-builder/src/repositories/ScreeningRepository.ts index dc5299d28..99bc1986a 100644 --- a/packages/app-builder/src/repositories/ScreeningRepository.ts +++ b/packages/app-builder/src/repositories/ScreeningRepository.ts @@ -58,6 +58,7 @@ export interface ScreeningRepository { enrichedData(args: { entityId: string }): Promise; getAvailableFilters(args: { feature: AvailableFeatures }): Promise; listSavedScreeningSearches(filters: SavedScreeningSearchFilters): Promise; + getFreeformSearch(args: { id: string }): Promise<{ id: string; matches: ScreeningMatchPayload[] }>; } export function makeGetScreeningRepository() { @@ -175,5 +176,12 @@ export function makeGetScreeningRepository() { order, }); }, + getFreeformSearch: async ({ id }) => { + const res = await marbleCoreApiClient.getFreeformSearch(id); + return { + id: res.id, + matches: res.matches ? R.map(res.matches, (match) => adaptScreeningMatchPayload(match)) : [], + }; + }, }); } diff --git a/packages/app-builder/src/server-fns/screenings.ts b/packages/app-builder/src/server-fns/screenings.ts index 9d2f32ac4..68a4188d1 100644 --- a/packages/app-builder/src/server-fns/screenings.ts +++ b/packages/app-builder/src/server-fns/screenings.ts @@ -225,6 +225,18 @@ export const listSavedFreeformSearchesFn = createServerFn({ method: 'GET' }) } }); +export const getFreeformSearchFn = createServerFn({ method: 'GET' }) + .middleware([authMiddleware]) + .inputValidator(z.object({ id: z.uuid() })) + .handler(async ({ context, data }) => { + try { + const result = await context.authInfo.screening.getFreeformSearch(data); + return { success: true as const, data: result }; + } catch { + return { success: false as const, error: 'Get freeform search failed' }; + } + }); + export const getEnrichedDataFn = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .inputValidator(getEnrichedDataInputSchema) diff --git a/packages/marble-api/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 279ca64d1..0328a436f 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -1075,7 +1075,7 @@ components: matches: type: array items: - $ref: '#/components/schemas/ScreeningMatchDto' + $ref: '#/components/schemas/ScreeningMatchPayloadDto' ScreeningFreeformSearchInputDto: type: object required: diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index 1f26bc7e1..f687f3c9a 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -1165,7 +1165,7 @@ export type ScreeningFreeformSearchDto = { }; search_config: ScreeningFreeformSearchConfigDto; is_saved: boolean; - matches?: ScreeningMatchDto[]; + matches?: ScreeningMatchPayloadDto[]; }; export type OpenSanctionsUpstreamDatasetFreshnessDto = { version: string; From 60ea5a58bde013187cb556eff72f587941b6ed6d Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 15 Jun 2026 14:49:15 +0200 Subject: [PATCH 16/23] Enhance display of specific fields - addresses (using address and addressEntity when available) - script used for names/alias/titles... refcactor all the utility functions in a single file refactor specific utility components in a single file refactor sanctions section (same design than associations and family) --- .../Data/DataVisualisation/DataField.tsx | 43 +- .../Screenings/EntityProperties.tsx | 112 ++++- .../Screenings/FreeformSearch/SaveSearch.tsx | 145 ------- .../FreeformSearch/ViewSavedResults.tsx | 5 +- .../Screenings/MatchCard/Associations.tsx | 9 +- .../Screenings/MatchCard/FamilyDetail.tsx | 18 +- .../Screenings/MatchCard/ModalSanction.tsx | 48 +++ .../Screenings/MatchCard/Sanctions.tsx | 65 +++ .../match-card-entity-components.tsx | 127 ++++++ .../MatchCard/match-card-utility-functions.ts | 389 ++++++++++++++++++ .../components/Screenings/MatchDetails.tsx | 76 +--- .../src/constants/screening-entity.tsx | 313 +++----------- .../src/locales/ar/screenings.json | 2 + .../src/locales/en/screenings.json | 2 + .../src/locales/fr/screenings.json | 2 + .../_app/_builder/screening-search/index.tsx | 2 +- packages/ui-design-system/src/utils.ts | 6 +- 17 files changed, 840 insertions(+), 524 deletions(-) delete mode 100644 packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx create mode 100644 packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx create mode 100644 packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx create mode 100644 packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx create mode 100644 packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts diff --git a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx index a0b7c0cfe..52ea999e9 100644 --- a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx +++ b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx @@ -36,7 +36,8 @@ import { const MapView = lazy(() => import('./MapView').then((m) => ({ default: m.MapView }))); -const codeClassName = 'font-mono border border-grey-border rounded-sm p-1 bg-surface-card'; +const codeClassName = (className?: string) => + cn('font-mono border border-grey-border rounded-sm p-1 bg-surface-card', className); const subClassName = 'grid gap-1 px-2 py-1 border border-grey-border bg-grey-background-light rounded-lg'; type DataFieldProps = { @@ -258,8 +259,16 @@ function StringCode() { return StringCodeComponent({ value }); } -export function StringCodeComponent({ value, children }: { value?: string; children?: ReactNode }) { - return {value ?? children ?? '-'}; +export function StringCodeComponent({ + value, + children, + className, +}: { + value?: string; + children?: ReactNode; + className?: string; +}) { + return {value ?? children ?? '-'}; } function StringEmail() { @@ -356,7 +365,7 @@ function StringVpn() { const value = useStringValue(); if (!value) return {t('data:no_vpn')}; return ( - + {t('data:vpn')} {'-'} {value} @@ -366,7 +375,7 @@ function StringVpn() { function StringId() { const value = useStringValue(); - if (!value) return ; + if (!value) return ; return ( {value} @@ -418,7 +427,7 @@ function StringIban() { if (!value) return ; // Format the IBAN in groups of 4 characters separated by a space const strIban = value.replace(/(.{4})/g, '$1 ').trim(); - return {strIban}; + return {strIban}; } function StringCurrency() { @@ -427,7 +436,7 @@ function StringCurrency() { const currency = cc.code(value); if (!currency) return {value}; return ( - + {currency?.code} {'-'} {currency?.currency} @@ -445,15 +454,17 @@ export function DateDatetimeComponent({ value, withTime = true, monospaced = false, + className, }: { value: string; withTime?: boolean; monospaced?: boolean; + className?: string; }) { const formatDateTime = useFormatDateTime(); const date = new Date(value); return ( - + {formatDateTime(date, { dateStyle: 'short', timeStyle: withTime ? 'short' : undefined })} ); @@ -466,7 +477,7 @@ function DataGpsCoords() { const options = useOptions(); const mapHeight = options?.mapHeight ?? MAP_HEIGHT; - if (!value || !opts) return -; + if (!value || !opts) return -; return (
    @@ -570,21 +581,21 @@ function BooleanYesNo() { function EnumValues() { const value = useStringValue(); - if (!value) return ; - return {value}; + if (!value) return ; + return {value}; } function DataIpAddress() { const value = useStringValue(); const metaData = useFieldMetaData(); const [isOpen, setIsOpen] = useState(false); - if (!value) return ; - if (!metaData) return {value}; + if (!value) return ; + if (!metaData) return {value}; return (
    + + ) : null} + + ) : isScriptTaggedProperty(property) ? ( + + {(values as string[]).map((value, index) => ( + + ))} + {restItemsCount > 0 ? ( +
  • + +
  • + ) : null} +
    ) : values.length > 0 ? ( - {values.map((v, i) => ( + {(values as string[]).map((v, i) => ( {i === values.length - 1 || isPropertyListed(property) ? null : } diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx deleted file mode 100644 index a356dcb0f..000000000 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { FormErrorOrDescription } from '@app-builder/components/Form/Tanstack/FormErrorOrDescription'; -import { FormInput } from '@app-builder/components/Form/Tanstack/FormInput'; -import { FormLabel } from '@app-builder/components/Form/Tanstack/FormLabel'; -import { useEntityName } from '@app-builder/hooks/useEntityName'; -// import { useSaveFreeformSearchMutation } from '@app-builder/queries/screening/freeform-search'; -import { getFieldErrors, handleSubmit } from '@app-builder/utils/form'; -import { useForm } from '@tanstack/react-form'; -import { useState } from 'react'; -// import toast from 'react-hot-toast'; -import { useTranslation } from 'react-i18next'; -import { Button, Modal } from 'ui-design-system'; -import { Icon } from 'ui-icons'; -import { z } from 'zod/v4'; -import { FreeformSearchState } from './FreeformSearchPage'; - -const saveSearchFormSchema = z.object({ - name: z.string().min(1).max(120), -}); - -export const SaveSearch = ({ search }: { search: FreeformSearchState }) => { - const { t } = useTranslation(['screenings', 'common']); - const [open, setOpen] = useState(false); - // const saveSearchMutation = useSaveFreeformSearchMutation(); - const { getEntityName } = useEntityName(); - - const form = useForm({ - defaultValues: { name: '' }, - onSubmit: ({ value, formApi }) => { - if (!formApi.state.isValid) return; - // saveSearchMutation - // .mutateAsync({ - // name: value.name, - // inputs: search.inputs, - // results: search.results, - // }) - // .then((res) => { - // if (res.success) { - // toast.success(t('screenings:freeform_search.save.success')); - // setOpen(false); - // form.reset(); - // } else { - // toast.error(t('common:errors.unknown')); - // } - // }) - // .catch(() => toast.error(t('common:errors.unknown'))); - }, - validators: { - onSubmit: saveSearchFormSchema, - }, - }); - - const filledFields = Object.entries(search.inputs.fields).filter(([, v]) => v && v.length > 0); - - return ( - - - - - -
    - {t('screenings:freeform_search.save.title')} -
    - {t('screenings:freeform_search.save.description')} -
    -

    - {t('screenings:freeform_search.save.summary_title')} -

    -
    - - {filledFields.map(([key, value]) => ( - - ))} - - {search.inputs.threshold !== undefined ? ( - - ) : null} - {search.inputs.limit !== undefined ? ( - - ) : null} - -
    -
    - - - {(field) => ( -
    - {t('screenings:freeform_search.save.name_label')} - field.handleChange(e.currentTarget.value)} - onBlur={field.handleBlur} - valid={field.state.meta.errors.length === 0} - placeholder={t('screenings:freeform_search.save.name_placeholder')} - /> - -
    - )} -
    -
    - - - - - {/* */} - -
    -
    -
    - ); -}; - -function SummaryRow({ label, value }: { label: string; value: string }) { - return ( -
    - {label} - {value} -
    - ); -} diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index 43b4731fb..bb94914cd 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -1,7 +1,8 @@ import { CursorPaginationButtons, usePaginationsButton } from '@app-builder/components/Decisions/PaginationButtons'; import { DateRangeFilter } from '@app-builder/components/Filters'; import { PanelContainer, PanelContent, PanelFooter, PanelRoot } from '@app-builder/components/Panel/Panel'; -import { IconDot, SEARCH_ENTITIES, SearchableSchema } from '@app-builder/constants/screening-entity'; +import { IconDot } from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; +import { SEARCH_ENTITIES, SearchableSchema } from '@app-builder/constants/screening-entity'; import { type PaginationParams } from '@app-builder/models/pagination'; import { type SavedScreeningSearch } from '@app-builder/models/screening'; import { @@ -255,7 +256,7 @@ function FilterValues({ filter }: { filter: SavedScreeningSearch['search_config' {key}:{value?.datasets?.length ?? 0} )} - {value?.topics && } + {value?.topics && } ))}
    diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx index a40045c7d..d8f02acbb 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx @@ -1,5 +1,5 @@ import { StringCodeComponent } from '@app-builder/components/Data/DataVisualisation/DataField'; -import { IconDot } from '@app-builder/constants/screening-entity'; +import { IconDot } from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; import { AssociationEntity } from '@app-builder/models/screening'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,10 +8,11 @@ import { ExpandableGroupTagLine } from 'ui-design-system'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; +import { getPersonName } from './match-card-utility-functions'; const MAX_ASSOCIATIONS = 5; -type AssociationRow = { +export type AssociationRow = { key: string; association: AssociationEntity; id: string; @@ -72,7 +73,7 @@ export const Associations = ({ associations }: { associations: AssociationEntity ) : ( - {properties.name?.[0] ?? properties.alias?.[0]} + {getPersonName(row)} ), , @@ -100,7 +101,7 @@ export const Associations = ({ associations }: { associations: AssociationEntity
    - +
    {association.properties.sourceUrl && association.properties.sourceUrl.length > 0 && ( diff --git a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx index 763dc91ec..b564fec6a 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx @@ -1,4 +1,4 @@ -import { IconDot } from '@app-builder/constants/screening-entity'; +import { IconDot } from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; import { type FamilyPersonEntity, type FamilyRelationshipEntry, @@ -13,6 +13,7 @@ import { Icon } from 'ui-icons'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; +import { getPersonName } from './match-card-utility-functions'; const MAX_FAMILY_MEMBERS = 5; @@ -26,7 +27,7 @@ export type FamilyDetailProps = { familyMembers: RelationEntity; }; -type FamilyMemberRow = { +export type FamilyMemberRow = { key: string; member: FamilyPersonEntity | FamilyRelativeEntity; id: string; @@ -77,15 +78,6 @@ function flattenFamilyMembers( return rows; } -function getPersonName(entity: FamilyPersonEntity | FamilyRelativeEntity) { - const { firstName, lastName, name, alias } = entity.properties; - if (firstName?.[0] && lastName?.[0]) return `${firstName[0]} ${lastName[0]}`.trim(); - if (name?.[0]) return name[0]; - if (alias?.[0]) return alias[0]; - - return '?'; -} - export function FamilyDetail({ familyMembers, relation }: FamilyDetailProps) { const { t } = useTranslation(['screenings', 'common']); const [showAll, setShowAll] = useState(false); @@ -114,7 +106,7 @@ export function FamilyDetail({ familyMembers, relation } ) : ( - {properties.alias?.[0] ?? properties.name?.[0]} + {getPersonName(row)} ), , @@ -134,7 +126,7 @@ export function FamilyDetail({ familyMembers, relation }
    - +
    {member.properties.sourceUrl && member.properties.sourceUrl.length > 0 && ( diff --git a/packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx b/packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx new file mode 100644 index 000000000..295b4bdb8 --- /dev/null +++ b/packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx @@ -0,0 +1,48 @@ +import { type PropertyForSchema } from '@app-builder/constants/screening-entity'; +import { type ScreeningSanctionEntity } from '@app-builder/models/screening'; +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from 'ui-design-system'; +import { Icon } from 'ui-icons'; +import { EntityProperties } from '../EntityProperties'; + +const sanctionProps = [ + 'country', + 'authority', + 'authorityId', + 'startDate', + 'endDate', + 'listingDate', + 'program', + 'programId', + 'programUrl', + 'summary', + 'reason', + 'sourceUrl', +] satisfies PropertyForSchema<'Sanction'>[]; + +export function ModalSanction({ sanction }: { sanction: ScreeningSanctionEntity }) { + const { t } = useTranslation(['screenings', 'common']); + + return ( + + + + + + {t('screenings:sanction_detail.title')} +
    + +
    + + + + + +
    +
    + ); +} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx new file mode 100644 index 000000000..56c1160bb --- /dev/null +++ b/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx @@ -0,0 +1,65 @@ +import { IconDot } from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; +import { type ScreeningSanctionEntity } from '@app-builder/models/screening'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ExpandableGroupTagLine } from 'ui-design-system'; +import { ModalSanction } from './ModalSanction'; + +const MAX_SANCTIONS = 5; + +function getSanctionLabel(sanction: ScreeningSanctionEntity) { + return sanction.properties['authority']?.[0] ?? sanction.id; +} + +export function Sanctions({ sanctions }: { sanctions: ScreeningSanctionEntity[] | undefined }) { + const { t } = useTranslation(['screenings', 'common']); + const [showAll, setShowAll] = useState(false); + + const rows = useMemo(() => sanctions ?? [], [sanctions]); + const hiddenCount = Math.max(0, rows.length - MAX_SANCTIONS); + const visibleRows = showAll ? rows : rows.slice(0, MAX_SANCTIONS); + + if (rows.length === 0) return null; + + return ( +
      + {visibleRows.map((sanction, rowIndex) => { + const isFirstElement = rowIndex === 0; + const label = getSanctionLabel(sanction); + + const expandableItems = [ + , + + {label} + , + ]; + + return ( +
    • +
      + {isFirstElement &&
      {t('screenings:entity.property.sanctions')}
      } +
      +
      +
      + + +
      +
      +
    • + ); + })} + {hiddenCount > 0 && !showAll && ( +
    • + + +
    • + )} +
    + ); +} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx b/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx new file mode 100644 index 000000000..61e5c2d34 --- /dev/null +++ b/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx @@ -0,0 +1,127 @@ +import { + DateBirthdateComponent, + StringCountryComponent, +} from '@app-builder/components/Data/DataVisualisation/DataField'; +import { HighlightText } from '@app-builder/components/Screenings/HighlightText'; +import { screeningsI18n } from '@app-builder/components/Screenings/screenings-i18n'; +import { getDateFnsLocale } from '@app-builder/services/i18n/i18n-config'; +import { useFormatLanguage } from '@app-builder/utils/format'; +import { formatDuration as dateFnsFormatDuration } from 'date-fns/formatDuration'; +import { useTranslation } from 'react-i18next'; +import { cn, Tag } from 'ui-design-system'; +import { Icon } from 'ui-icons'; +import { + type AddressEntity, + type BirthDateRange, + classifyBirthDate, + detectNativeScript, + formatBirthDateRange, + getAgeYears, + getBirthDateRange, +} from './match-card-utility-functions'; + +export function ParseAlias({ value, highlightText }: { value: string; highlightText?: string }) { + const { t } = useTranslation(screeningsI18n); + const script = detectNativeScript(value); + + return ( +
  • + {script ? ( + + {t('screenings:entity.property.native_script', { script })} + + ) : null} + +
  • + ); +} + +export function ParseAddress({ address }: { address: AddressEntity }) { + const { t } = useTranslation(screeningsI18n); + const notesLabel = + address.properties.notes ?? + t('screenings:entity.property.address.notes.associated', { defaultValue: 'Associated' }); + const cityLabel = [address.properties.postalCode, address.properties.city].filter(Boolean).join(' ').trim(); + + const segments = [ + + {notesLabel} + , + address.properties.street ? {address.properties.street} : null, + cityLabel ? {cityLabel} : null, + address.properties.country + ? StringCountryComponent({ value: address.properties.country, withCountryName: true }) + : null, + ].filter((segment): segment is NonNullable => segment !== null); + + return ( +
  • + + {segments.map((segment, index) => ( +
    + {segment} + {index < segments.length - 1 ? : null} +
    + ))} +
  • + ); +} + +export function IconDot({ dark, spaced }: { dark?: boolean; spaced?: boolean }) { + return ( + + ); +} + +function ApproximativeAge({ ageYears, range }: { ageYears: number; range: BirthDateRange | null }) { + const language = useFormatLanguage(); + const { t } = useTranslation(screeningsI18n); + const formatted = dateFnsFormatDuration( + { years: Math.max(0, Math.round(ageYears)) }, + { locale: getDateFnsLocale(language) }, + ); + const rangeLabel = range ? formatBirthDateRange(range, language, t) : null; + + return ( + + + ~{formatted} + {rangeLabel ? ` ${rangeLabel}` : null} + + + ); +} + +export function BirthdDateAverage({ values }: { values: string[] }) { + const classified = values + .map((value) => ({ value, kind: classifyBirthDate(value) })) + .filter( + (entry): entry is { value: string; kind: NonNullable> } => + entry.kind !== null, + ); + + if (classified.length === 0) { + const fallback = values[0]; + return fallback ? DateBirthdateComponent({ value: fallback }) : null; + } + + if (classified.length === 1) { + const entry = classified[0]!; + if (entry.kind === 'full') { + return DateBirthdateComponent({ value: entry.value }); + } + return ; + } + + const averageAge = classified.reduce((sum, { value, kind }) => sum + getAgeYears(value, kind), 0) / classified.length; + + return ; +} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts b/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts new file mode 100644 index 000000000..a3bd56bbc --- /dev/null +++ b/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts @@ -0,0 +1,389 @@ +import { FamilyPersonEntity, FamilyRelativeEntity } from '@app-builder/models/screening'; +import { formatDateTimeWithoutPresets } from '@app-builder/utils/format'; +import { tryCatch } from '@app-builder/utils/tryCatch'; +import { Temporal } from 'temporal-polyfill'; +import { match } from 'ts-pattern'; +import { getCountryByName } from 'ui-design-system'; +import { z } from 'zod'; +import { AssociationRow } from './Associations'; +import { FamilyMemberRow } from './FamilyDetail'; + +export type AddressEntity = { + caption?: string; + properties: { + full?: string; + street?: string; + city?: string; + country?: string; + postalCode?: string; + notes?: string; + }; +}; + +const addressEntityUuidSchema = z.uuid(); + +const rawAddressEntitySchema = z.object({ + caption: z.string().optional(), + schema: z.string().optional(), + properties: z.object({ + full: z.array(z.string()).optional(), + street: z.array(z.string()).optional(), + city: z.array(z.string()).optional(), + country: z.array(z.string()).optional(), + postalCode: z.array(z.string()).optional(), + notes: z.array(z.string()).optional(), + }), +}); + +type RawAddressEntity = z.infer; + +const POSTAL_CODE_PATTERN = /^\d[\dA-Za-z\s-]*$/; +const MAX_DISPLAY_PATH_SEGMENT_LENGTH = 20; +const EMBEDDED_ENGLISH_DATE_REGEX = + /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+\d{4}\b/g; +const FULL_BIRTH_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const YEAR_ONLY_BIRTH_DATE_PATTERN = /^\d{4}$/; + +const SCRIPT_DETECTORS: Array<{ name: string; test: RegExp }> = [ + { name: 'Arabic', test: /\p{Script=Arabic}/u }, + { name: 'Hangul Syllables', test: /\p{Script=Hangul}/u }, + { name: 'Cyrillic', test: /\p{Script=Cyrillic}/u }, + { name: 'Han', test: /\p{Script=Han}/u }, + { name: 'Hiragana', test: /\p{Script=Hiragana}/u }, + { name: 'Katakana', test: /\p{Script=Katakana}/u }, + { name: 'Hebrew', test: /\p{Script=Hebrew}/u }, + { name: 'Greek', test: /\p{Script=Greek}/u }, + { name: 'Devanagari', test: /\p{Script=Devanagari}/u }, + { name: 'Thai', test: /\p{Script=Thai}/u }, + { name: 'Armenian', test: /\p{Script=Armenian}/u }, + { name: 'Georgian', test: /\p{Script=Georgian}/u }, +]; + +const LATIN_OR_NEUTRAL_CHAR = + /\p{Script=Latin}|\p{General_Category=Punctuation}|\p{General_Category=Separator}|\p{General_Category=Number}/u; + +export type TextSegment = { type: 'text'; value: string } | { type: 'date'; value: string }; +export type BirthDateKind = 'full' | 'year'; +export type BirthDateRange = + | { type: 'years'; minYear: number; maxYear: number } + | { type: 'same_year'; min: Temporal.PlainDate; max: Temporal.PlainDate; year: number } + | { type: 'full'; min: Temporal.PlainDate; max: Temporal.PlainDate }; + +/** + * Addresses + */ + +function firstValue(values: string[] | undefined): string | undefined { + return values?.[0]; +} + +function normalizeAddressKey(value: string): string { + return value.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +function resolveCountryCode(country: string): string { + if (country.length === 2) return country.toLowerCase(); + return getCountryByName(country)?.isoAlpha2 ?? country; +} + +function joinStreetSegments(segments: string[]): string { + if (segments.length === 0) return ''; + if (segments.length === 1) return segments[0]!; + + const [first, second, ...rest] = segments; + if (/^\d+$/.test(first!)) { + return [`${first}, ${second}`, ...rest].filter(Boolean).join(', '); + } + + return segments.join(', '); +} + +function trimLongTrailingPathSegments(pathname: string): string { + const segments = pathname.split('/').filter((segment) => segment.length > 0); + let lastRemoved: string | undefined; + + while (segments.length > 0 && (segments.at(-1)?.length ?? 0) > MAX_DISPLAY_PATH_SEGMENT_LENGTH) { + lastRemoved = segments.pop(); + } + + if (!lastRemoved) { + return segments.length > 0 ? `/${segments.join('/')}` : '/'; + } + + const base = segments.length > 0 ? `/${segments.join('/')}` : ''; + return `${base}/${segmentPreview(lastRemoved)}...`; +} + +function segmentPreview(segment: string): string { + const decoded = tryCatch(() => decodeURIComponent(segment)); + const value = decoded.ok ? decoded.value : segment; + return value.slice(0, MAX_DISPLAY_PATH_SEGMENT_LENGTH); +} + +function formatPathForDisplay(pathname: string): string { + if (pathname === '/') return pathname; + return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +function decodeUrlForDisplay(url: string): string { + const decoded = tryCatch(() => decodeURI(url)); + return decoded.ok ? decoded.value : url; +} + +export function isAddressEntityUuid(value: unknown): boolean { + return typeof value === 'string' && addressEntityUuidSchema.safeParse(value).success; +} + +export function isRawAddressEntity(value: unknown): value is RawAddressEntity { + return rawAddressEntitySchema.safeParse(value).success; +} + +export function adaptRawAddressEntity(raw: RawAddressEntity): AddressEntity { + const full = firstValue(raw.properties.full); + const caption = raw.caption ?? full; + + return { + caption, + properties: { + full, + street: firstValue(raw.properties.street), + city: firstValue(raw.properties.city), + country: firstValue(raw.properties.country) ? resolveCountryCode(firstValue(raw.properties.country)!) : undefined, + postalCode: firstValue(raw.properties.postalCode), + notes: firstValue(raw.properties.notes), + }, + }; +} + +export function parseStringAddress(address: string): AddressEntity { + const segments = address + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + let country: string | undefined; + let postalCode: string | undefined; + let city: string | undefined; + let streetSegments = [...segments]; + + if (streetSegments.length > 0) { + const last = streetSegments.pop()!; + country = resolveCountryCode(last); + } + + if (streetSegments.length > 0) { + const candidatePostalCode = streetSegments.at(-1)!; + if (POSTAL_CODE_PATTERN.test(candidatePostalCode)) { + postalCode = streetSegments.pop(); + } + } + + if (streetSegments.length > 0) { + city = streetSegments.pop(); + } + + const street = joinStreetSegments(streetSegments); + + return { + caption: address, + properties: { + full: address, + street: street || undefined, + city, + postalCode, + country, + }, + }; +} + +export function getAddressDedupeKey(entity: AddressEntity): string { + const key = entity.properties.full ?? entity.caption; + if (key) return normalizeAddressKey(key); + + return normalizeAddressKey( + [entity.properties.street, entity.properties.city, entity.properties.postalCode, entity.properties.country] + .filter(Boolean) + .join(', '), + ); +} + +export function mergeAddresses(addressStrings: string[], rawAddressEntities: unknown[]): AddressEntity[] { + const merged = new Map(); + + for (const raw of rawAddressEntities) { + if (isAddressEntityUuid(raw) || !isRawAddressEntity(raw)) continue; + const entity = adaptRawAddressEntity(raw); + merged.set(getAddressDedupeKey(entity), entity); + } + + for (const address of addressStrings) { + const entity = parseStringAddress(address); + const key = getAddressDedupeKey(entity); + if (!merged.has(key)) { + merged.set(key, entity); + } + } + + return [...merged.values()]; +} + +/** + * names/aliases + */ + +export function getPersonName(entity: FamilyMemberRow | AssociationRow | FamilyPersonEntity | FamilyRelativeEntity) { + const { firstName, lastName, name, alias } = entity.properties; + if (firstName?.[0] || lastName?.[0]) return `${firstName?.[0]} ${lastName?.[0]}`.trim(); + if (name?.[0]) return name[0]; + if (alias?.[0]) return alias[0]; + + return '?'; +} + +export function splitTextWithEmbeddedDates(value: string): TextSegment[] { + const segments: TextSegment[] = []; + let lastIndex = 0; + + for (const match of value.matchAll(EMBEDDED_ENGLISH_DATE_REGEX)) { + const index = match.index ?? 0; + if (index > lastIndex) { + segments.push({ type: 'text', value: value.slice(lastIndex, index) }); + } + segments.push({ type: 'date', value: match[0] }); + lastIndex = index + match[0].length; + } + + if (lastIndex < value.length) { + segments.push({ type: 'text', value: value.slice(lastIndex) }); + } + + return segments; +} + +export function detectNativeScript(value: string): string | null { + const counts = new Map(); + + for (const char of value) { + if (LATIN_OR_NEUTRAL_CHAR.test(char)) continue; + + const detector = SCRIPT_DETECTORS.find(({ test }) => test.test(char)); + if (!detector) continue; + + counts.set(detector.name, (counts.get(detector.name) ?? 0) + 1); + } + + if (counts.size === 0) return null; + + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]![0]; +} + +/** + * urls + */ + +export function cleanUrl(url: string) { + const parsed = tryCatch(() => new URL(url)); + if (!parsed.ok) return url; + + const { origin, hostname, pathname } = parsed.value; + + const cleanedPathname = match(hostname) + .when( + (host) => host === 'web.archive.org', + () => { + const archiveMatch = pathname.match(/^\/web\/(\d{14})/); + if (!archiveMatch) return trimLongTrailingPathSegments(pathname); + + const kept = `/web/${archiveMatch[1]}`; + const removedSegments = pathname + .slice(kept.length) + .split('/') + .filter((segment) => segment.length > 0); + const lastRemoved = removedSegments.at(-1); + + if (!lastRemoved) return kept; + + return `${kept}/${segmentPreview(lastRemoved)}...`; + }, + ) + .otherwise(() => trimLongTrailingPathSegments(pathname)); + + return decodeUrlForDisplay(formatPathForDisplay(origin + cleanedPathname)); +} + +/** + * birth dates + */ + +export function classifyBirthDate(value: string): BirthDateKind | null { + if (FULL_BIRTH_DATE_PATTERN.test(value)) return 'full'; + if (YEAR_ONLY_BIRTH_DATE_PATTERN.test(value)) return 'year'; + return null; +} + +export function toBirthDate(value: string, kind: BirthDateKind): Temporal.PlainDate { + if (kind === 'full') return Temporal.PlainDate.from(value); + return Temporal.PlainDate.from(`${value}-07-01`); +} + +export function getAgeYears(value: string, kind: BirthDateKind): number { + const today = Temporal.Now.plainDateISO(); + return Math.max(0, toBirthDate(value, kind).until(today, { largestUnit: 'year' }).years); +} + +export function getBirthDateRange(classified: { value: string; kind: BirthDateKind }[]): BirthDateRange | null { + if (classified.length < 2) return null; + + const allYearOnly = classified.every((entry) => entry.kind === 'year'); + if (allYearOnly) { + const years = classified.map((entry) => Number(entry.value)).sort((a, b) => a - b); + const minYear = years[0]!; + const maxYear = years[years.length - 1]!; + if (minYear === maxYear) return null; + return { type: 'years', minYear, maxYear }; + } + + const dates = classified.map((entry) => toBirthDate(entry.value, entry.kind)).sort(Temporal.PlainDate.compare); + const min = dates[0]!; + const max = dates[dates.length - 1]!; + if (Temporal.PlainDate.compare(min, max) === 0) return null; + + if (min.year === max.year) { + return { type: 'same_year', min, max, year: min.year }; + } + + return { type: 'full', min, max }; +} + +export function formatPlainDate( + date: Temporal.PlainDate, + language: string, + options: Intl.DateTimeFormatOptions, +): string { + return formatDateTimeWithoutPresets(date.toString(), { language, ...options }); +} + +export function formatBirthDateRange( + range: BirthDateRange, + language: string, + t: (key: string, options?: object) => string, +) { + return match(range) + .with({ type: 'years' }, ({ minYear, maxYear }) => + t('screenings:entity.property.birthDate.approximative_age.between_years', { min: minYear, max: maxYear }), + ) + .with({ type: 'same_year' }, ({ min, max, year }) => + t('screenings:entity.property.birthDate.approximative_age.between_same_year', { + min: formatPlainDate(min, language, { day: 'numeric', month: 'long' }), + max: formatPlainDate(max, language, { day: 'numeric', month: 'long' }), + year, + }), + ) + .with({ type: 'full' }, ({ min, max }) => + t('screenings:entity.property.birthDate.approximative_age.between_dates', { + min: formatPlainDate(min, language, { day: 'numeric', month: 'long', year: 'numeric' }), + max: formatPlainDate(max, language, { day: 'numeric', month: 'long', year: 'numeric' }), + }), + ) + .exhaustive(); +} diff --git a/packages/app-builder/src/components/Screenings/MatchDetails.tsx b/packages/app-builder/src/components/Screenings/MatchDetails.tsx index b4ca1941f..92ec982dc 100644 --- a/packages/app-builder/src/components/Screenings/MatchDetails.tsx +++ b/packages/app-builder/src/components/Screenings/MatchDetails.tsx @@ -1,20 +1,15 @@ -import { type PropertyForSchema } from '@app-builder/constants/screening-entity'; import { type FamilyPersonEntity, type FamilyRelationshipEntry, type FamilyRelativeEntity, type ScreeningMatch, - type ScreeningSanctionEntity, } from '@app-builder/models/screening'; -import { ReactNode, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button, Modal } from 'ui-design-system'; -import { Icon } from 'ui-icons'; +import { ReactNode, useMemo } from 'react'; import { EntityProperties } from './EntityProperties'; import { Associations } from './MatchCard/Associations'; import { FamilyDetail } from './MatchCard/FamilyDetail'; import { MemberShip } from './MatchCard/MemberShip'; -import { screeningsI18n } from './screenings-i18n'; +import { Sanctions } from './MatchCard/Sanctions'; export type MatchDetailsProps = { entity: ScreeningMatch['payload']; @@ -22,21 +17,6 @@ export type MatchDetailsProps = { highlightText?: string; }; -const sanctionProps = [ - 'country', - 'authority', - 'authorityId', - 'startDate', - 'endDate', - 'listingDate', - 'program', - 'programId', - 'programUrl', - 'summary', - 'reason', - 'sourceUrl', -] satisfies PropertyForSchema<'Sanction'>[]; - function relationshipKey({ source, value }: FamilyRelationshipEntry) { return `${source}:${value}`; } @@ -52,11 +32,6 @@ function dedupeRelationships(entries: FamilyRelationshipEntry[]) { } export function MatchDetails({ entity, before, highlightText }: MatchDetailsProps) { - const { t } = useTranslation(screeningsI18n); - const [selectedSanction, setSelectedSanction] = useState(null); - - const [isOpen, setIsOpen] = useState(false); - const deduplicatedEntity = useMemo(() => { if (entity.schema !== 'Person') return entity; @@ -169,51 +144,8 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp return (
    - - {t('screenings:entity.property.sanctions')} -
    - {entity.properties.sanctions.map((sanction) => ( -
    - {sanction.properties['authority']} - - - -
    - ))} -
    - -
    - {t('screenings:sanction_detail.title')} - - - -
    -
    - {selectedSanction ? ( - - ) : null} -
    -
    - - ) : null - } - /> + + {entity.schema === 'Person' && entity.properties?.['membershipMember']?.length && entity.properties?.['membershipMember']?.[0]?.caption ? ( diff --git a/packages/app-builder/src/constants/screening-entity.tsx b/packages/app-builder/src/constants/screening-entity.tsx index 271f1e103..ee2d13b55 100644 --- a/packages/app-builder/src/constants/screening-entity.tsx +++ b/packages/app-builder/src/constants/screening-entity.tsx @@ -8,44 +8,26 @@ import { } from '@app-builder/components/Data/DataVisualisation/DataField'; import { ExternalLink } from '@app-builder/components/ExternalLink'; import { HighlightText } from '@app-builder/components/Screenings/HighlightText'; -import { screeningsI18n } from '@app-builder/components/Screenings/screenings-i18n'; +import { + cleanUrl, + splitTextWithEmbeddedDates, +} from '@app-builder/components/Screenings/MatchCard/match-card-utility-functions'; import { FormatContext } from '@app-builder/contexts/FormatContext'; import { type OpenSanctionEntitySchema } from '@app-builder/models/screening'; -import { getDateFnsLocale } from '@app-builder/services/i18n/i18n-config'; -import { formatDateTimeWithoutPresets, useFormatLanguage } from '@app-builder/utils/format'; -import { tryCatch } from '@app-builder/utils/tryCatch'; -import { formatDuration as dateFnsFormatDuration } from 'date-fns/formatDuration'; +import { formatDateTimeWithoutPresets } from '@app-builder/utils/format'; import { Fragment } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Temporal } from 'temporal-polyfill'; import { match } from 'ts-pattern'; -import { cn, getCountryByName } from 'ui-design-system'; -import { Icon } from 'ui-icons'; - -const EMBEDDED_ENGLISH_DATE_REGEX = - /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+\d{4}\b/g; - -type TextSegment = { type: 'text'; value: string } | { type: 'date'; value: string }; -function splitTextWithEmbeddedDates(value: string): TextSegment[] { - const segments: TextSegment[] = []; - let lastIndex = 0; - - for (const match of value.matchAll(EMBEDDED_ENGLISH_DATE_REGEX)) { - const index = match.index ?? 0; - if (index > lastIndex) { - segments.push({ type: 'text', value: value.slice(lastIndex, index) }); - } - segments.push({ type: 'date', value: match[0] }); - lastIndex = index + match[0].length; - } - - if (lastIndex < value.length) { - segments.push({ type: 'text', value: value.slice(lastIndex) }); - } - - return segments; -} +export { + BirthdDateAverage, + IconDot, + ParseAddress, + ParseAlias, +} from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; +export { + cleanUrl, + detectNativeScript, +} from '@app-builder/components/Screenings/MatchCard/match-card-utility-functions'; export type PropertyDataType = 'string' | 'country' | 'url' | 'date' | 'wikidataId'; export type PropertyForSchema< @@ -86,6 +68,7 @@ export const schemaProperties = { 'wikidataId', 'keywords', 'address', + 'addressEntity', 'program', 'notes', 'createdAt', @@ -192,7 +175,6 @@ type PropertyFormat = | 'dateOfBirth' | 'country' | 'countryFlag' - | 'address' | 'position' | 'email' | 'phone'; @@ -203,7 +185,8 @@ type PropertyMetadata = { }; const propertyMetadata: Record = { - address: { type: 'string', format: 'address' }, + address: { type: 'string' }, + addressEntity: { type: 'string' }, alias: { type: 'string' }, appearance: { type: 'string' }, birthDate: { type: 'string', format: 'dateOfBirth' }, @@ -278,8 +261,34 @@ const propertyMetadata: Record = { listingDate: { type: 'string', format: 'dateTime' }, }; +/** + * fields that are tagged with a native script + */ +const SCRIPT_TAGGED_PROPERTIES = [ + 'name', + 'title', + 'firstName', + 'secondName', + 'middleName', + 'fatherName', + 'motherName', + 'lastName', + 'nameSuffix', + 'alias', + 'weakAlias', + 'previousName', +] as const satisfies ScreeningEntityProperty[]; + // list of properties that are displayed in a list, not inline -export const propertyMetadataList: Array = ['address']; +export const propertyMetadataList: Array = [ + 'address', + 'addressEntity', + ...SCRIPT_TAGGED_PROPERTIES, +]; + +export function isScriptTaggedProperty(property: ScreeningEntityProperty) { + return (SCRIPT_TAGGED_PROPERTIES as readonly string[]).includes(property); +} export function getSanctionEntityProperties(schema: OpenSanctionEntitySchema) { let currentSchema: OpenSanctionEntitySchema | null = schema; @@ -330,71 +339,6 @@ export function createPropertyTransformer(ctx: { language: string; formatLanguag }; } -const MAX_DISPLAY_PATH_SEGMENT_LENGTH = 20; - -export function cleanUrl(url: string) { - const parsed = tryCatch(() => new URL(url)); - if (!parsed.ok) return url; - - const { origin, hostname, pathname } = parsed.value; - - const cleanedPathname = match(hostname) - .when( - (host) => host === 'web.archive.org', - () => { - const archiveMatch = pathname.match(/^\/web\/(\d{14})/); - if (!archiveMatch) return trimLongTrailingPathSegments(pathname); - - const kept = `/web/${archiveMatch[1]}`; - const removedSegments = pathname - .slice(kept.length) - .split('/') - .filter((segment) => segment.length > 0); - const lastRemoved = removedSegments.at(-1); - - if (!lastRemoved) return kept; - - return `${kept}/${segmentPreview(lastRemoved)}...`; - }, - ) - .otherwise(() => trimLongTrailingPathSegments(pathname)); - - return decodeUrlForDisplay(formatPathForDisplay(origin + cleanedPathname)); -} - -function trimLongTrailingPathSegments(pathname: string): string { - const segments = pathname.split('/').filter((segment) => segment.length > 0); - let lastRemoved: string | undefined; - - while (segments.length > 0 && (segments.at(-1)?.length ?? 0) > MAX_DISPLAY_PATH_SEGMENT_LENGTH) { - lastRemoved = segments.pop(); - } - - if (!lastRemoved) { - return segments.length > 0 ? `/${segments.join('/')}` : '/'; - } - - const base = segments.length > 0 ? `/${segments.join('/')}` : ''; - return `${base}/${segmentPreview(lastRemoved)}...`; -} - -function segmentPreview(segment: string): string { - const decoded = tryCatch(() => decodeURIComponent(segment)); - const value = decoded.ok ? decoded.value : segment; - return value.slice(0, MAX_DISPLAY_PATH_SEGMENT_LENGTH); -} - -function formatPathForDisplay(pathname: string): string { - if (pathname === '/') return pathname; - return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; -} - -function decodeUrlForDisplay(url: string): string { - const decoded = tryCatch(() => decodeURI(url)); - return decoded.ok ? decoded.value : url; -} - -// format values using the components of the data field component function formatedValue(format: PropertyFormat | undefined, value: string, highlightText?: string) { const { locale } = FormatContext.useValue(); return match(format) @@ -410,7 +354,6 @@ function formatedValue(format: PropertyFormat | undefined, value: string, highli .with('dateOfBirth', () => DateBirthdateComponent({ value })) .with('country', () => StringCountryComponent({ value, withCountryName: true })) .with('countryFlag', () => StringCountryComponent({ value, withCountryName: false })) - .with('address', () => ) .with('position', () => {value}) .with('email', () => StringEmailComponent({ value })) .with('phone', () => StringPhoneComponent({ value })) @@ -418,8 +361,6 @@ function formatedValue(format: PropertyFormat | undefined, value: string, highli .exhaustive(); } -// highlight the dates and show them in the locale formet -// not sure if this one is really relevant function TextWithEmbeddedDates({ value, highlightText }: { value: string; highlightText?: string }) { const segments = splitTextWithEmbeddedDates(value); @@ -432,7 +373,12 @@ function TextWithEmbeddedDates({ value, highlightText }: { value: string; highli {segments.map((segment, index) => segment.type === 'date' ? ( - {DateDatetimeComponent({ value: segment.value, withTime: false, monospaced: true })} + {DateDatetimeComponent({ + value: segment.value, + withTime: false, + monospaced: true, + className: 'p-0 inline-block', + })} ) : segment.value.length > 0 ? ( @@ -441,160 +387,3 @@ function TextWithEmbeddedDates({ value, highlightText }: { value: string; highli ); } - -// try to figure out the country to display it with the fancy flag -function ParseAddress({ address }: { address: string }) { - const addressParts = address.split(','); - return ( -
  • - - {addressParts.map((part, index) => { - const trimmedPart = part.trim(); - const isLastPart = index === addressParts.length - 1; - const country = isLastPart ? getCountryByName(trimmedPart) : undefined; - - return ( -
    - {country ? ( - StringCountryComponent({ value: country.isoAlpha2, withCountryName: true }) - ) : ( - {trimmedPart} - )} - {index < addressParts.length - 1 ? : null} -
    - ); - })} -
  • - ); -} - -export function IconDot({ dark, spaced }: { dark?: boolean; spaced?: boolean }) { - return ( - - ); -} - -const FULL_BIRTH_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; -const YEAR_ONLY_BIRTH_DATE_PATTERN = /^\d{4}$/; - -type BirthDateKind = 'full' | 'year'; - -function classifyBirthDate(value: string): BirthDateKind | null { - if (FULL_BIRTH_DATE_PATTERN.test(value)) return 'full'; - if (YEAR_ONLY_BIRTH_DATE_PATTERN.test(value)) return 'year'; - return null; -} - -function toBirthDate(value: string, kind: BirthDateKind): Temporal.PlainDate { - if (kind === 'full') return Temporal.PlainDate.from(value); - return Temporal.PlainDate.from(`${value}-07-01`); -} - -function getAgeYears(value: string, kind: BirthDateKind): number { - const today = Temporal.Now.plainDateISO(); - return Math.max(0, toBirthDate(value, kind).until(today, { largestUnit: 'year' }).years); -} - -type BirthDateRange = - | { type: 'years'; minYear: number; maxYear: number } - | { type: 'same_year'; min: Temporal.PlainDate; max: Temporal.PlainDate; year: number } - | { type: 'full'; min: Temporal.PlainDate; max: Temporal.PlainDate }; - -function getBirthDateRange(classified: { value: string; kind: BirthDateKind }[]): BirthDateRange | null { - if (classified.length < 2) return null; - - const allYearOnly = classified.every((entry) => entry.kind === 'year'); - if (allYearOnly) { - const years = classified.map((entry) => Number(entry.value)).sort((a, b) => a - b); - const minYear = years[0]!; - const maxYear = years[years.length - 1]!; - if (minYear === maxYear) return null; - return { type: 'years', minYear, maxYear }; - } - - const dates = classified.map((entry) => toBirthDate(entry.value, entry.kind)).sort(Temporal.PlainDate.compare); - const min = dates[0]!; - const max = dates[dates.length - 1]!; - if (Temporal.PlainDate.compare(min, max) === 0) return null; - - if (min.year === max.year) { - return { type: 'same_year', min, max, year: min.year }; - } - - return { type: 'full', min, max }; -} - -function formatPlainDate(date: Temporal.PlainDate, language: string, options: Intl.DateTimeFormatOptions): string { - return formatDateTimeWithoutPresets(date.toString(), { language, ...options }); -} - -function formatBirthDateRange(range: BirthDateRange, language: string, t: (key: string, options?: object) => string) { - return match(range) - .with({ type: 'years' }, ({ minYear, maxYear }) => - t('screenings:entity.property.birthDate.approximative_age.between_years', { min: minYear, max: maxYear }), - ) - .with({ type: 'same_year' }, ({ min, max, year }) => - t('screenings:entity.property.birthDate.approximative_age.between_same_year', { - min: formatPlainDate(min, language, { day: 'numeric', month: 'long' }), - max: formatPlainDate(max, language, { day: 'numeric', month: 'long' }), - year, - }), - ) - .with({ type: 'full' }, ({ min, max }) => - t('screenings:entity.property.birthDate.approximative_age.between_dates', { - min: formatPlainDate(min, language, { day: 'numeric', month: 'long', year: 'numeric' }), - max: formatPlainDate(max, language, { day: 'numeric', month: 'long', year: 'numeric' }), - }), - ) - .exhaustive(); -} - -function ApproximativeAge({ ageYears, range }: { ageYears: number; range: BirthDateRange | null }) { - const language = useFormatLanguage(); - const { t } = useTranslation(screeningsI18n); - const formatted = dateFnsFormatDuration( - { years: Math.max(0, Math.round(ageYears)) }, - { locale: getDateFnsLocale(language) }, - ); - const rangeLabel = range ? formatBirthDateRange(range, language, t) : null; - - return ( - - - ~{formatted} - {rangeLabel ? ` ${rangeLabel}` : null} - - - ); -} - -export function BirthdDateAverage({ values }: { values: string[] }) { - const classified = values - .map((value) => ({ value, kind: classifyBirthDate(value) })) - .filter((entry): entry is { value: string; kind: BirthDateKind } => entry.kind !== null); - - if (classified.length === 0) { - const fallback = values[0]; - return fallback ? DateBirthdateComponent({ value: fallback }) : null; - } - - if (classified.length === 1) { - const entry = classified[0]!; - if (entry.kind === 'full') { - return DateBirthdateComponent({ value: entry.value }); - } - return ; - } - - const averageAge = classified.reduce((sum, { value, kind }) => sum + getAgeYears(value, kind), 0) / classified.length; - - return ; -} diff --git a/packages/app-builder/src/locales/ar/screenings.json b/packages/app-builder/src/locales/ar/screenings.json index cef4e94af..5e743580b 100644 --- a/packages/app-builder/src/locales/ar/screenings.json +++ b/packages/app-builder/src/locales/ar/screenings.json @@ -6,6 +6,7 @@ "dataset_other": "قوائم", "enrich_button": "إثراء", "entity.property.address": "العنوان", + "entity.property.address.notes.associated": "مرتبط", "entity.property.address.short": "العنوان", "entity.property.alias": "اسم مستعار", "entity.property.appearance": "المظهر", @@ -56,6 +57,7 @@ "entity.property.nameSuffix": "لاحقة الاسم", "entity.property.nationality": "الجنسية", "entity.property.nationality.short": "الجنسية", + "entity.property.native_script": "الكتابة الأصلية: {{script}}", "entity.property.notes": "ملاحظات", "entity.property.npiCode": "NPI", "entity.property.ogrnCode": "OGRN", diff --git a/packages/app-builder/src/locales/en/screenings.json b/packages/app-builder/src/locales/en/screenings.json index 32914311c..979b825da 100644 --- a/packages/app-builder/src/locales/en/screenings.json +++ b/packages/app-builder/src/locales/en/screenings.json @@ -6,6 +6,7 @@ "dataset_other": "Datasets", "enrich_button": "Enrich", "entity.property.address": "Address", + "entity.property.address.notes.associated": "Associated", "entity.property.address.short": "Addr.", "entity.property.alias": "Alias", "entity.property.appearance": "Appearance", @@ -56,6 +57,7 @@ "entity.property.nameSuffix": "Name suffix", "entity.property.nationality": "Nationality", "entity.property.nationality.short": "Nat.", + "entity.property.native_script": "Native Script: {{script}}", "entity.property.notes": "Notes", "entity.property.npiCode": "NPI", "entity.property.ogrnCode": "OGRN", diff --git a/packages/app-builder/src/locales/fr/screenings.json b/packages/app-builder/src/locales/fr/screenings.json index dcd59d10d..e5f3768e9 100644 --- a/packages/app-builder/src/locales/fr/screenings.json +++ b/packages/app-builder/src/locales/fr/screenings.json @@ -6,6 +6,7 @@ "dataset_other": "Listes", "enrich_button": "Enrichir", "entity.property.address": "Adresse", + "entity.property.address.notes.associated": "Associé", "entity.property.address.short": "Adr.", "entity.property.alias": "Alias", "entity.property.appearance": "Apparence", @@ -56,6 +57,7 @@ "entity.property.nameSuffix": "Suffixe", "entity.property.nationality": "Nationalité", "entity.property.nationality.short": "Nat.", + "entity.property.native_script": "Écriture native : {{script}}", "entity.property.notes": "Notes", "entity.property.npiCode": "Code NPI", "entity.property.ogrnCode": "Code OGRN", diff --git a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx index 40e449215..483aeb0fb 100644 --- a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx +++ b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx @@ -87,7 +87,7 @@ function ScreeningSearchIndexPage() { {searchState.searchId && ( - diff --git a/packages/ui-design-system/src/utils.ts b/packages/ui-design-system/src/utils.ts index 8771d55db..1a0c9fca5 100644 --- a/packages/ui-design-system/src/utils.ts +++ b/packages/ui-design-system/src/utils.ts @@ -17,7 +17,11 @@ const twMerge = extendTailwindMerge({ 'text-small', 'text-tiny', // New ones ], - p: ['p-v2-xxxl', 'p-v2-xxl', 'p-v2-xl', 'p-v2-lg', 'p-v2-md', 'p-v2-sm', 'p-v2-xs', 'p-v2-xxs', 'p-0'], + }, + }, + extend: { + classGroups: { + p: ['p-v2-xxxl', 'p-v2-xxl', 'p-v2-xl', 'p-v2-lg', 'p-v2-md', 'p-v2-sm', 'p-v2-xs', 'p-v2-xxs'], }, }, }); From 57c31fc16e2d52067483eb26c49097ada87fd50b Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 15 Jun 2026 15:44:05 +0200 Subject: [PATCH 17/23] display dataset title instead of key --- .../ScreeningCaseDetailPage.tsx | 11 +- .../dataset-utils.ts | 22 ++ .../FreeformSearch/FreeformMatchCard.tsx | 11 +- .../FreeformSearchPrint/PrintResultCard.tsx | 15 +- .../Screenings/MatchCard/Associations.tsx | 15 +- .../Screenings/MatchCard/MatchCard.tsx | 14 +- .../match-card-entity-components.tsx | 34 ++- .../MatchCard/match-card-utility-functions.ts | 222 ++++++++++++++++-- 8 files changed, 284 insertions(+), 60 deletions(-) diff --git a/packages/app-builder/src/components/CaseManager/ScreeningCaseDetail/ScreeningCaseDetailPage.tsx b/packages/app-builder/src/components/CaseManager/ScreeningCaseDetail/ScreeningCaseDetailPage.tsx index db713a698..e93ddbc9a 100644 --- a/packages/app-builder/src/components/CaseManager/ScreeningCaseDetail/ScreeningCaseDetailPage.tsx +++ b/packages/app-builder/src/components/CaseManager/ScreeningCaseDetail/ScreeningCaseDetailPage.tsx @@ -4,6 +4,7 @@ import { DataListGrid } from '@app-builder/components/DataModelExplorer/DataList import { PanelContainer, PanelContent } from '@app-builder/components/Panel'; import { PanelRoot, PanelSharpFactory } from '@app-builder/components/Panel/Panel'; import { EntityProperties } from '@app-builder/components/Screenings/EntityProperties'; +import { EntityDatasetsList } from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; import { TopicTag } from '@app-builder/components/Screenings/TopicTag'; import { SquareTag } from '@app-builder/components/SquareTag'; import { Case, type CaseDetail } from '@app-builder/models/cases'; @@ -197,11 +198,11 @@ const ScreeningEntityDetailsPanel = ({ entity }: { entity: OpenSanctionEntityPay <>
    {t('screenings:dataset', { count: entity.datasets.length })}
    -
      - {entity.datasets.map((dataset) => ( -
    • {dataset}
    • - ))} -
    +
    } diff --git a/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-utils.ts b/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-utils.ts index 2ebc6bc2e..5f6a970f5 100644 --- a/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-utils.ts +++ b/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-utils.ts @@ -104,6 +104,28 @@ export function findDatasetOrTopicByKey( return undefined; } +/** Finds a dataset by its plain name key across all sections of a normalized list config. */ +export function findDatasetByName( + filters: ListConfigFilters | null | undefined, + name: string, +): DatasetTopicSearchResult | undefined { + const normalizedName = name?.trim() ?? ''; + if (normalizedName === '' || !filters) return undefined; + + for (const section of Object.values(filters)) { + if (!section) continue; + for (const group of section.datasets ?? []) { + for (const dataset of group.datasets) { + if (dataset.name === normalizedName) { + return { name: dataset.name, title: dataset.title ?? dataset.name }; + } + } + } + } + + return undefined; +} + // All fields derived from listConfig.global.topics + conventions: // - keys[0]: always persisted when the global topic switch is active // - keys[1] / value: persisted when the switch is ON diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx index f4c9eea06..6da73284e 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx @@ -4,6 +4,7 @@ import { useGetEnrichedDataQuery } from '@app-builder/queries/screening/get-enri import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Collapsible, cn, Tag } from 'ui-design-system'; +import { EntityDatasetsList } from '../MatchCard/match-card-entity-components'; import { MatchDetails } from '../MatchDetails'; import { screeningsI18n } from '../screenings-i18n'; import { TopicsDisplay } from '../TopicsDisplay'; @@ -52,13 +53,7 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm, background
    {t('screenings:match.datasets.title')}
    -
      - {entity.datasets.map((name, index) => ( -
    • - {name} -
    • - ))} -
    +
    ) : null} @@ -84,7 +79,7 @@ export function FreeFormMatchCardDataContent({ }) { const { t } = useTranslation(screeningsI18n); const enrichedData = useGetEnrichedDataQuery({ entityId }, isOpen); - if (enrichedData.isLoading) return ; + if (enrichedData.isLoading) return ; if (!enrichedData.data?.success) return
    {t('screenings:match.enriched_data_error')}
    ; const entity = enrichedData.data.data; if (!entity) return
    {t('screenings:match.enriched_data_error')}
    ; diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPrint/PrintResultCard.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPrint/PrintResultCard.tsx index 907c57ade..674b37865 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPrint/PrintResultCard.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchPrint/PrintResultCard.tsx @@ -2,7 +2,7 @@ import { type ScreeningMatchPayload } from '@app-builder/models/screening'; import { type FunctionComponent } from 'react'; import { useTranslation } from 'react-i18next'; import { Tag } from 'ui-design-system'; - +import { EntityDatasetsList } from '../../MatchCard/match-card-entity-components'; import { MatchDetails } from '../../MatchDetails'; import { screeningsI18n } from '../../screenings-i18n'; import { TopicsDisplay } from '../../TopicsDisplay'; @@ -50,13 +50,12 @@ export const PrintResultCard: FunctionComponent = ({ entit
    {t('screenings:match.datasets.title')}
    -
      - {entity.datasets.map((name, index) => ( -
    • - {name} -
    • - ))} -
    +
    ) : null} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx index d8f02acbb..f29f7d3b1 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx @@ -1,14 +1,14 @@ import { StringCodeComponent } from '@app-builder/components/Data/DataVisualisation/DataField'; import { IconDot } from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; import { AssociationEntity } from '@app-builder/models/screening'; -import { useMemo, useState } from 'react'; +import { Fragment, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as R from 'remeda'; import { ExpandableGroupTagLine } from 'ui-design-system'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; -import { getPersonName } from './match-card-utility-functions'; +import { cleanUrl, getPersonName } from './match-card-utility-functions'; const MAX_ASSOCIATIONS = 5; @@ -107,20 +107,21 @@ export const Associations = ({ associations }: { associations: AssociationEntity {association.properties.sourceUrl && association.properties.sourceUrl.length > 0 && (
    {t('screenings:match.family.source.label')}
    -
      +
      {association.properties.sourceUrl.map((url, urlIdx) => ( -
    • + - {url} + {cleanUrl(url)} -
    • + {urlIdx < association.properties.sourceUrl!.length - 1 ? : null} + ))} -
    +
    )}
    diff --git a/packages/app-builder/src/components/Screenings/MatchCard/MatchCard.tsx b/packages/app-builder/src/components/Screenings/MatchCard/MatchCard.tsx index 8718f2c04..897e471bb 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/MatchCard.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/MatchCard.tsx @@ -11,6 +11,7 @@ import { StatusTag } from '../StatusTag'; import { screeningsI18n } from '../screenings-i18n'; import { TopicsDisplay } from '../TopicsDisplay'; import { CommentLine } from './CommentLine'; +import { EntityDatasetsList } from './match-card-entity-components'; type MatchCardProps = { screening: Screening; @@ -90,13 +91,12 @@ export const MatchCard = ({
    {t('screenings:match.datasets.title')}
    -
      - {entity?.datasets?.map((name, index) => ( -
    • - {name} -
    • - ))} -
    +
    ) : null} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx b/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx index 61e5c2d34..1fd44e61f 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx @@ -2,8 +2,11 @@ import { DateBirthdateComponent, StringCountryComponent, } from '@app-builder/components/Data/DataVisualisation/DataField'; +import { findDatasetByName, useDatasetTitle } from '@app-builder/components/ListAndTopicConfiguration/dataset-utils'; import { HighlightText } from '@app-builder/components/Screenings/HighlightText'; import { screeningsI18n } from '@app-builder/components/Screenings/screenings-i18n'; +import { type AvailableFeatures } from '@app-builder/models/screening'; +import { useListConfigQuery } from '@app-builder/queries/screening/lists-config'; import { getDateFnsLocale } from '@app-builder/services/i18n/i18n-config'; import { useFormatLanguage } from '@app-builder/utils/format'; import { formatDuration as dateFnsFormatDuration } from 'date-fns/formatDuration'; @@ -20,9 +23,36 @@ import { getBirthDateRange, } from './match-card-utility-functions'; +type EntityDatasetsListProps = { + datasets: string[]; + useCase: AvailableFeatures; + listClassName?: string; + itemClassName?: string; +}; + +export function EntityDatasetsList({ datasets, useCase, listClassName, itemClassName }: EntityDatasetsListProps) { + const listConfigQuery = useListConfigQuery(useCase); + const { formatItemName } = useDatasetTitle(); + + return ( +
      + {datasets.map((name, index) => { + const found = findDatasetByName(listConfigQuery.data?.filters, name); + const label = found ? formatItemName(found) : name; + return ( +
    • + {label} +
    • + ); + })} +
    + ); +} + export function ParseAlias({ value, highlightText }: { value: string; highlightText?: string }) { + const language = useFormatLanguage(); const { t } = useTranslation(screeningsI18n); - const script = detectNativeScript(value); + const script = detectNativeScript(value, language); return (
  • @@ -55,7 +85,7 @@ export function ParseAddress({ address }: { address: AddressEntity }) { ].filter((segment): segment is NonNullable => segment !== null); return ( -
  • +
  • {segments.map((segment, index) => (
    diff --git a/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts b/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts index a3bd56bbc..1aeb4fe25 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts +++ b/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts @@ -44,23 +44,199 @@ const EMBEDDED_ENGLISH_DATE_REGEX = const FULL_BIRTH_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const YEAR_ONLY_BIRTH_DATE_PATTERN = /^\d{4}$/; -const SCRIPT_DETECTORS: Array<{ name: string; test: RegExp }> = [ - { name: 'Arabic', test: /\p{Script=Arabic}/u }, - { name: 'Hangul Syllables', test: /\p{Script=Hangul}/u }, - { name: 'Cyrillic', test: /\p{Script=Cyrillic}/u }, - { name: 'Han', test: /\p{Script=Han}/u }, - { name: 'Hiragana', test: /\p{Script=Hiragana}/u }, - { name: 'Katakana', test: /\p{Script=Katakana}/u }, - { name: 'Hebrew', test: /\p{Script=Hebrew}/u }, - { name: 'Greek', test: /\p{Script=Greek}/u }, - { name: 'Devanagari', test: /\p{Script=Devanagari}/u }, - { name: 'Thai', test: /\p{Script=Thai}/u }, - { name: 'Armenian', test: /\p{Script=Armenian}/u }, - { name: 'Georgian', test: /\p{Script=Georgian}/u }, -]; - -const LATIN_OR_NEUTRAL_CHAR = - /\p{Script=Latin}|\p{General_Category=Punctuation}|\p{General_Category=Separator}|\p{General_Category=Number}/u; +const ALWAYS_SKIPPED_SCRIPTS = ['Common', 'Inherited'] as const; + +function getSkippedScripts(language: string): Set { + const isArabicInterface = language === 'ar' || language.startsWith('ar-'); + return new Set(isArabicInterface ? [...ALWAYS_SKIPPED_SCRIPTS, 'Arabic'] : [...ALWAYS_SKIPPED_SCRIPTS, 'Latin']); +} +const NEUTRAL_CHAR = /\p{General_Category=Punctuation}|\p{General_Category=Separator}|\p{General_Category=Number}/u; + +const SCRIPT_NAME_OVERRIDES: Record = { + Hangul: 'Hangul Syllables', + Han: 'Han', +}; + +const UNICODE_SCRIPTS = [ + 'Adlam', + 'Ahom', + 'Anatolian_Hieroglyphs', + 'Arabic', + 'Armenian', + 'Avestan', + 'Balinese', + 'Bamum', + 'Bassa_Vah', + 'Batak', + 'Bengali', + 'Bhaiksuki', + 'Brahmi', + 'Braille', + 'Buginese', + 'Buhid', + 'Canadian_Aboriginal', + 'Carian', + 'Caucasian_Albanian', + 'Chakma', + 'Cham', + 'Cherokee', + 'Chorasmian', + 'Coptic', + 'Cuneiform', + 'Cypriot', + 'Cypro_Minoan', + 'Cyrillic', + 'Deseret', + 'Devanagari', + 'Dives_Akuru', + 'Dogra', + 'Duployan', + 'Egyptian_Hieroglyphs', + 'Elbasan', + 'Elymaic', + 'Ethiopic', + 'Georgian', + 'Glagolitic', + 'Gothic', + 'Grantha', + 'Greek', + 'Gujarati', + 'Gunjala_Gondi', + 'Gurmukhi', + 'Han', + 'Hangul', + 'Hanifi_Rohingya', + 'Hanunoo', + 'Hatran', + 'Hebrew', + 'Hiragana', + 'Imperial_Aramaic', + 'Inscriptional_Pahlavi', + 'Inscriptional_Parthian', + 'Javanese', + 'Kaithi', + 'Kannada', + 'Katakana', + 'Kawi', + 'Kayah_Li', + 'Kharoshthi', + 'Khitan_Small_Script', + 'Khmer', + 'Khojki', + 'Khudawadi', + 'Lao', + 'Lepcha', + 'Limbu', + 'Linear_A', + 'Linear_B', + 'Lisu', + 'Lycian', + 'Lydian', + 'Mahajani', + 'Makasar', + 'Malayalam', + 'Mandaic', + 'Manichaean', + 'Marchen', + 'Masaram_Gondi', + 'Medefaidrin', + 'Meetei_Mayek', + 'Mende_Kikakui', + 'Meroitic_Cursive', + 'Meroitic_Hieroglyphs', + 'Miao', + 'Modi', + 'Mongolian', + 'Mro', + 'Multani', + 'Myanmar', + 'Nabataean', + 'Nag_Mundari', + 'Nandinagari', + 'New_Tai_Lue', + 'Newa', + 'Nko', + 'Nushu', + 'Nyiakeng_Puachue_Hmong', + 'Ogham', + 'Ol_Chiki', + 'Old_Hungarian', + 'Old_Italic', + 'Old_North_Arabian', + 'Old_Permic', + 'Old_Persian', + 'Old_Sogdian', + 'Old_South_Arabian', + 'Old_Turkic', + 'Old_Uyghur', + 'Oriya', + 'Osage', + 'Osmanya', + 'Pahawh_Hmong', + 'Palmyrene', + 'Pau_Cin_Hau', + 'Phags_Pa', + 'Phoenician', + 'Psalter_Pahlavi', + 'Rejang', + 'Runic', + 'Samaritan', + 'Saurashtra', + 'Sharada', + 'Shavian', + 'Siddham', + 'SignWriting', + 'Sinhala', + 'Sogdian', + 'Sora_Sompeng', + 'Soyombo', + 'Sundanese', + 'Syloti_Nagri', + 'Syriac', + 'Tagalog', + 'Tagbanwa', + 'Tai_Le', + 'Tai_Tham', + 'Tai_Viet', + 'Takri', + 'Tamil', + 'Tangsa', + 'Tangut', + 'Telugu', + 'Thaana', + 'Thai', + 'Tibetan', + 'Tifinagh', + 'Tirhuta', + 'Toto', + 'Ugaritic', + 'Vai', + 'Vithkuqi', + 'Wancho', + 'Warang_Citi', + 'Yezidi', + 'Yi', + 'Zanabazar_Square', +] as const; + +const SCRIPT_CHAR_TESTERS = UNICODE_SCRIPTS.map((script) => ({ + script, + test: new RegExp(`\\p{Script=${script}}`, 'u'), +})); + +function formatScriptName(script: string): string { + return SCRIPT_NAME_OVERRIDES[script] ?? script.replaceAll('_', ' '); +} + +function getCharScript(char: string): string | null { + if (NEUTRAL_CHAR.test(char)) return null; + + for (const { script, test } of SCRIPT_CHAR_TESTERS) { + if (test.test(char)) return script; + } + + return null; +} export type TextSegment = { type: 'text'; value: string } | { type: 'date'; value: string }; export type BirthDateKind = 'full' | 'year'; @@ -260,16 +436,16 @@ export function splitTextWithEmbeddedDates(value: string): TextSegment[] { return segments; } -export function detectNativeScript(value: string): string | null { +export function detectNativeScript(value: string, language: string): string | null { + const skippedScripts = getSkippedScripts(language); const counts = new Map(); for (const char of value) { - if (LATIN_OR_NEUTRAL_CHAR.test(char)) continue; - - const detector = SCRIPT_DETECTORS.find(({ test }) => test.test(char)); - if (!detector) continue; + const script = getCharScript(char); + if (!script || skippedScripts.has(script)) continue; - counts.set(detector.name, (counts.get(detector.name) ?? 0) + 1); + const scriptName = formatScriptName(script); + counts.set(scriptName, (counts.get(scriptName) ?? 0) + 1); } if (counts.size === 0) return null; From 8f536c49eb12c6c3309a4381a908a95a26fae243 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 15 Jun 2026 17:09:13 +0200 Subject: [PATCH 18/23] update search filter with new api (date range, saved only) deduplicate sanctions --- .../FreeformSearch/ViewSavedResults.tsx | 67 +++++++++---------- .../Screenings/MatchCard/Sanctions.tsx | 21 +++++- .../src/locales/ar/screenings.json | 1 + .../src/locales/en/screenings.json | 1 + .../src/locales/fr/screenings.json | 1 + packages/app-builder/src/models/screening.ts | 4 +- .../src/repositories/ScreeningRepository.ts | 5 +- .../app-builder/src/server-fns/screenings.ts | 11 +-- .../openapis/marblecore-api/screenings.yml | 17 +++++ .../src/generated/marblecore-api.ts | 9 ++- 10 files changed, 88 insertions(+), 49 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index bb94914cd..a6e0b1517 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -13,11 +13,11 @@ import { useOrganizationDetails } from '@app-builder/services/organization/organ import { useOrganizationUsers } from '@app-builder/services/organization/organization-users'; import { formatDateTimeWithoutPresets, formatDuration, useFormatLanguage } from '@app-builder/utils/format'; import { omitUndefined } from '@app-builder/utils/omit-undefined'; -import { useDebouncedCallbackRef } from '@marble/shared'; import { ScreeningConfigBodySectionDto } from 'marble-api'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Avatar, Button, Collapsible, cn, Input, MenuCommand, Separator, Tag } from 'ui-design-system'; +import { Temporal } from 'temporal-polyfill'; +import { Avatar, Button, Collapsible, cn, MenuCommand, Separator, Switch, Tag } from 'ui-design-system'; import { Icon } from 'ui-icons'; import FreeformMatchCard from './FreeformMatchCard'; @@ -32,17 +32,17 @@ interface DynamicDateRangeFilter { } type DateRangeFilterValue = StaticDateRangeFilter | DynamicDateRangeFilter | null; -// function toIsoRange(value: DateRangeFilterValue): { fromDate?: string; toDate?: string } { -// if (!value) return {}; -// if (value.type === 'static') { -// return { fromDate: value.startDate || undefined, toDate: value.endDate || undefined }; -// } -// const now = Temporal.Now.zonedDateTimeISO(); -// return { -// fromDate: now.add(value.fromNow).toInstant().toString(), -// toDate: now.toInstant().toString(), -// }; -// } +function toIsoRange(value: DateRangeFilterValue): { createdAfter?: string; createdBefore?: string } { + if (!value) return {}; + if (value.type === 'static') { + return { createdAfter: value.startDate || undefined, createdBefore: value.endDate || undefined }; + } + const now = Temporal.Now.zonedDateTimeISO(); + return { + createdAfter: now.add(value.fromNow).toInstant().toString(), + createdBefore: now.toInstant().toString(), + }; +} const PAGE_SIZES = [25, 50, 100] as const; type PageSize = (typeof PAGE_SIZES)[number]; @@ -51,31 +51,27 @@ export const ViewSavedResults = () => { const { t } = useTranslation(['screenings', 'common']); const [open, setOpen] = useState(false); - const [nameInput, setNameInput] = useState(''); - // const [name, setName] = useState(''); + const [isSaved, setIsSaved] = useState(true); const [dateRange, setDateRange] = useState(null); const [ownerId, setOwnerId] = useState(undefined); const [paginationParams, setPaginationParams] = useState({ limit: 25 }); + const { createdAfter, createdBefore } = useMemo(() => toIsoRange(dateRange), [dateRange]); const filterValues = useMemo( () => omitUndefined({ userId: ownerId, - isSaved: true, + isSaved, + createdAfter, + createdBefore, }), - [ownerId], + [ownerId, isSaved, createdAfter, createdBefore], ); - const applyName = useDebouncedCallbackRef((_value: string) => { - // setName(value); - setPaginationParams((prev) => ({ limit: prev.limit ?? 25 })); - }, 300); - const resetPagination = () => { setPaginationParams((prev) => ({ limit: prev.limit ?? 25 })); }; - // const { fromDate, toDate } = useMemo(() => toIsoRange(dateRange), [dateRange]); const query = useSavedFreeformSearchesQuery( omitUndefined({ ...filterValues, @@ -118,16 +114,6 @@ export const ViewSavedResults = () => {
    - { - setNameInput(e.currentTarget.value); - applyName(e.currentTarget.value); - }} - /> { @@ -142,6 +128,19 @@ export const ViewSavedResults = () => { resetPagination(); }} /> +
    + { + setIsSaved(value); + resetPagination(); + }} + /> + +
    @@ -225,7 +224,7 @@ function SavedSearchRow({ search }: { search: SavedScreeningSearch }) { {formatDateTimeWithoutPresets(search.created_at, { language, dateStyle: 'short' })} {search.is_saved ? ( - {search?.matches?.length ?? 0} + {search.nb_hits} ) : ( {t('screenings:freeform_search.saved_results.not_saved')} )} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx index 56c1160bb..b99f7b18e 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx @@ -11,11 +11,30 @@ function getSanctionLabel(sanction: ScreeningSanctionEntity) { return sanction.properties['authority']?.[0] ?? sanction.id; } +function getSanctionDedupeKey(sanction: ScreeningSanctionEntity) { + const normalizedProperties = Object.entries(sanction.properties) + .sort(([propertyA], [propertyB]) => propertyA.localeCompare(propertyB)) + .map(([property, values]) => [property, [...values].sort()] as const); + + return JSON.stringify(normalizedProperties); +} + +function dedupeSanctions(sanctions: ScreeningSanctionEntity[]) { + const seen = new Set(); + + return sanctions.filter((sanction) => { + const key = getSanctionDedupeKey(sanction); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + export function Sanctions({ sanctions }: { sanctions: ScreeningSanctionEntity[] | undefined }) { const { t } = useTranslation(['screenings', 'common']); const [showAll, setShowAll] = useState(false); - const rows = useMemo(() => sanctions ?? [], [sanctions]); + const rows = useMemo(() => dedupeSanctions(sanctions ?? []), [sanctions]); const hiddenCount = Math.max(0, rows.length - MAX_SANCTIONS); const visibleRows = showAll ? rows : rows.slice(0, MAX_SANCTIONS); diff --git a/packages/app-builder/src/locales/ar/screenings.json b/packages/app-builder/src/locales/ar/screenings.json index 5e743580b..a12143f8b 100644 --- a/packages/app-builder/src/locales/ar/screenings.json +++ b/packages/app-builder/src/locales/ar/screenings.json @@ -163,6 +163,7 @@ "freeform_search.saved_results.not_saved": "غير محفوظ", "freeform_search.saved_results.range": "من {{start}} إلى {{end}}", "freeform_search.saved_results.results_per_page": "النتائج لكل صفحة", + "freeform_search.saved_results.saved_only": "النتائج المحفوظة فقط", "freeform_search.saved_results.search_placeholder": "ابحث عن نتيجة...", "freeform_search.saved_results.select_owner": "اختر مالكًا", "freeform_search.saved_results.select_period": "اختر فترة", diff --git a/packages/app-builder/src/locales/en/screenings.json b/packages/app-builder/src/locales/en/screenings.json index 979b825da..e22e8d556 100644 --- a/packages/app-builder/src/locales/en/screenings.json +++ b/packages/app-builder/src/locales/en/screenings.json @@ -163,6 +163,7 @@ "freeform_search.saved_results.not_saved": "Not saved", "freeform_search.saved_results.range": "From {{start}} to {{end}}", "freeform_search.saved_results.results_per_page": "Results per page", + "freeform_search.saved_results.saved_only": "Saved results only", "freeform_search.saved_results.search_placeholder": "Search for a result...", "freeform_search.saved_results.select_owner": "Select owner", "freeform_search.saved_results.select_period": "Select period", diff --git a/packages/app-builder/src/locales/fr/screenings.json b/packages/app-builder/src/locales/fr/screenings.json index e5f3768e9..345913fc7 100644 --- a/packages/app-builder/src/locales/fr/screenings.json +++ b/packages/app-builder/src/locales/fr/screenings.json @@ -163,6 +163,7 @@ "freeform_search.saved_results.not_saved": "Non enregistré", "freeform_search.saved_results.range": "De {{start}} à {{end}}", "freeform_search.saved_results.results_per_page": "Résultats par page", + "freeform_search.saved_results.saved_only": "Uniquement les résultats sauvegardés", "freeform_search.saved_results.search_placeholder": "Rechercher un résultat...", "freeform_search.saved_results.select_owner": "Sélectionner un propriétaire", "freeform_search.saved_results.select_period": "Sélectionner une période", diff --git a/packages/app-builder/src/models/screening.ts b/packages/app-builder/src/models/screening.ts index 9fe7467a2..d922da932 100644 --- a/packages/app-builder/src/models/screening.ts +++ b/packages/app-builder/src/models/screening.ts @@ -731,8 +731,8 @@ export type SavedScreeningSearch = ScreeningFreeformSearchDto; export interface SavedScreeningSearchFilters { limit?: number; offsetId?: string; - order?: 'ASC' | 'DESC'; - sorting?: 'created_at'; + createdAfter?: string; + createdBefore?: string; userId?: string; apiKeyId?: string; isSaved?: boolean; diff --git a/packages/app-builder/src/repositories/ScreeningRepository.ts b/packages/app-builder/src/repositories/ScreeningRepository.ts index 99bc1986a..94e2a7da4 100644 --- a/packages/app-builder/src/repositories/ScreeningRepository.ts +++ b/packages/app-builder/src/repositories/ScreeningRepository.ts @@ -166,14 +166,15 @@ export function makeGetScreeningRepository() { getAiSuggestions: async ({ screeningId }) => { return R.map(await marbleCoreApiClient.getScreeningAiSuggestions(screeningId), adaptScreeningAiSuggestion); }, - listSavedScreeningSearches: async ({ isSaved, userId, apiKeyId, offsetId, limit, order }) => { + listSavedScreeningSearches: async ({ isSaved, userId, apiKeyId, offsetId, limit, createdAfter, createdBefore }) => { return await marbleCoreApiClient.listFreeformSearches({ savedOnly: isSaved, userId, apiKeyId, offsetId, limit, - order, + createdAfter, + createdBefore, }); }, getFreeformSearch: async ({ id }) => { diff --git a/packages/app-builder/src/server-fns/screenings.ts b/packages/app-builder/src/server-fns/screenings.ts index 68a4188d1..4c68f5167 100644 --- a/packages/app-builder/src/server-fns/screenings.ts +++ b/packages/app-builder/src/server-fns/screenings.ts @@ -102,16 +102,11 @@ export const getEnrichedDataInputSchema = z.object({ }); export type GetEnrichedDataInput = z.infer; -export const savedSearchFiltersSchema = z.object({ - fromDate: z.string().optional(), - toDate: z.string().optional(), - name: z.string().optional(), +const savedSearchFiltersSchema = z.object({ + createdAfter: z.string().optional(), + createdBefore: z.string().optional(), offsetId: z.string().optional(), - next: z.coerce.boolean().optional(), - previous: z.coerce.boolean().optional(), limit: z.coerce.number().min(1).max(100).optional(), - order: z.enum(['ASC', 'DESC']).optional(), - sorting: z.enum(['created_at']).optional(), userId: z.string().optional(), apiKeyId: z.string().optional(), isSaved: z.boolean().optional(), diff --git a/packages/marble-api/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 0328a436f..80bc88a51 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -388,6 +388,20 @@ required: false schema: type: boolean + - name: created_after + description: Filter searches created after this date + in: query + required: false + schema: + type: string + format: date-time + - name: created_before + description: Filter searches created before this date + in: query + required: false + schema: + type: string + format: date-time responses: '200': description: The list of past freeform searches @@ -1053,6 +1067,7 @@ components: - search_input - search_config - is_saved + - nb_hits properties: id: type: string @@ -1072,6 +1087,8 @@ components: $ref: '#/components/schemas/ScreeningFreeformSearchConfigDto' is_saved: type: boolean + nb_hits: + type: integer matches: type: array items: diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index f687f3c9a..6e2623e30 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -1165,6 +1165,7 @@ export type ScreeningFreeformSearchDto = { }; search_config: ScreeningFreeformSearchConfigDto; is_saved: boolean; + nb_hits: number; matches?: ScreeningMatchPayloadDto[]; }; export type OpenSanctionsUpstreamDatasetFreshnessDto = { @@ -4184,13 +4185,15 @@ export function freeformSearch(body?: { /** * List past freeform searches */ -export function listFreeformSearches({ limit, offsetId, order, userId, apiKeyId, savedOnly }: { +export function listFreeformSearches({ limit, offsetId, order, userId, apiKeyId, savedOnly, createdAfter, createdBefore }: { limit?: number; offsetId?: string; order?: "ASC" | "DESC"; userId?: string; apiKeyId?: string; savedOnly?: boolean; + createdAfter?: string; + createdBefore?: string; } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -4210,7 +4213,9 @@ export function listFreeformSearches({ limit, offsetId, order, userId, apiKeyId, order, user_id: userId, api_key_id: apiKeyId, - saved_only: savedOnly + saved_only: savedOnly, + created_after: createdAfter, + created_before: createdBefore }))}`, { ...opts })); From a298d2ac63573748beef1e76a37b435baaf981d1 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 15 Jun 2026 17:46:15 +0200 Subject: [PATCH 19/23] fix coderabbit review --- .../Data/DataVisualisation/DataField.tsx | 8 ++-- .../Screenings/EntityProperties.tsx | 24 +++++++----- .../FreeformSearch/ViewSavedResults.tsx | 10 ++--- .../Screenings/MatchCard/Associations.tsx | 6 +-- .../Screenings/MatchCard/FamilyDetail.tsx | 4 +- .../Screenings/MatchCard/ModalPerson.tsx | 6 +-- .../Screenings/MatchCard/ModalSanction.tsx | 2 +- .../MatchCard/match-card-utility-functions.ts | 13 ++++++- .../components/Screenings/MatchDetails.tsx | 32 +++++++++++----- .../src/constants/screening-entity.tsx | 2 +- .../src/locales/ar/screenings.json | 1 + .../src/locales/en/screenings.json | 1 + .../src/locales/fr/screenings.json | 1 + .../src/queries/screening/freeform-search.ts | 6 ++- .../_app/_builder/screening-search/index.tsx | 38 +++++++++---------- packages/ui-design-system/src/Modal/Modal.tsx | 2 +- 16 files changed, 94 insertions(+), 62 deletions(-) diff --git a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx index 52ea999e9..797c67747 100644 --- a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx +++ b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx @@ -316,7 +316,7 @@ function StringCity() { function StringCountry() { const value = useStringValue(); if (!value) return ; - return StringCountryComponent({ value }); + return ; } export function StringCountryComponent({ @@ -406,7 +406,7 @@ function StringFree() { function DateBirthdate() { const value = useStringValue(); if (!value) return ; - return DateBirthdateComponent({ value }); + return ; } export function DateBirthdateComponent({ value }: { value: string }) { @@ -417,7 +417,7 @@ export function DateBirthdateComponent({ value }: { value: string }) { return ( {age} - {formatDateTime(date, { dateStyle: 'short' })} + {formatDateTime(date, { dateStyle: 'short' })} ); } @@ -447,7 +447,7 @@ function StringCurrency() { function DateDatetime() { const value = useStringValue(); if (!value) return ; - return DateDatetimeComponent({ value }); + return ; } export function DateDatetimeComponent({ diff --git a/packages/app-builder/src/components/Screenings/EntityProperties.tsx b/packages/app-builder/src/components/Screenings/EntityProperties.tsx index a573c3e13..397cc9893 100644 --- a/packages/app-builder/src/components/Screenings/EntityProperties.tsx +++ b/packages/app-builder/src/components/Screenings/EntityProperties.tsx @@ -20,6 +20,7 @@ import { type OpenSanctionEntity } from '@app-builder/models/screening'; import { useFormatLanguage } from '@app-builder/utils/format'; import { Fragment, type ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Button } from 'ui-design-system'; import { screeningsI18n } from './screenings-i18n'; type PropertyRow = { @@ -138,15 +139,16 @@ export function EntityProperties({ ))} {restItemsCount > 0 ? (
  • - +
  • ) : null} @@ -157,15 +159,16 @@ export function EntityProperties({ ))} {restItemsCount > 0 ? (
  • - +
  • ) : null} @@ -180,20 +183,21 @@ export function EntityProperties({ {restItemsCount > 0 ? ( <> {isPropertyListed(property) ? null : } - + ) : null} ) : ( - not available + {t('screenings:match.not_available')} )}
    diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index a6e0b1517..a72445965 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -14,7 +14,7 @@ import { useOrganizationUsers } from '@app-builder/services/organization/organiz import { formatDateTimeWithoutPresets, formatDuration, useFormatLanguage } from '@app-builder/utils/format'; import { omitUndefined } from '@app-builder/utils/omit-undefined'; import { ScreeningConfigBodySectionDto } from 'marble-api'; -import { useMemo, useState } from 'react'; +import { Fragment, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Temporal } from 'temporal-polyfill'; import { Avatar, Button, Collapsible, cn, MenuCommand, Separator, Switch, Tag } from 'ui-design-system'; @@ -249,14 +249,14 @@ function FilterValues({ filter }: { filter: SavedScreeningSearch['search_config' {Object.entries(filter.filters) .filter(([, value]) => value.enabled) .map(([key, value], index) => ( - <> + {value?.datasets?.length && ( - + {key}:{value?.datasets?.length ?? 0} )} - {value?.topics && } - + {value?.topics && } + ))}
    ); diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx index f29f7d3b1..f4e4fead6 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx @@ -8,7 +8,7 @@ import { ExpandableGroupTagLine } from 'ui-design-system'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; -import { cleanUrl, getPersonName } from './match-card-utility-functions'; +import { cleanUrl, getPersonName, hasDisplayableName } from './match-card-utility-functions'; const MAX_ASSOCIATIONS = 5; @@ -24,7 +24,7 @@ function flattenAssociations(associations: AssociationEntity[]): AssociationRow[ associations.forEach((association, associationIndex) => { association.properties.person?.forEach((person, idx) => { - if (!person.properties?.name?.[0]) return; + if (!person.properties || !hasDisplayableName(person.properties)) return; rows.push({ key: `person-${associationIndex}-${person.id}-${idx}`, association, @@ -77,7 +77,7 @@ export const Associations = ({ associations }: { associations: AssociationEntity
    ), , - rel ? ( + rel?.length ? ( {rel?.map((r, index) => ( diff --git a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx index b564fec6a..b32ce7cde 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx @@ -13,7 +13,7 @@ import { Icon } from 'ui-icons'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; -import { getPersonName } from './match-card-utility-functions'; +import { getPersonName, hasDisplayableName } from './match-card-utility-functions'; const MAX_FAMILY_MEMBERS = 5; @@ -64,7 +64,7 @@ function flattenFamilyMembers( (member.properties.relationship ?? []).map((value) => ({ value, source: relation })); entities?.forEach(({ id, properties }, idx) => { - if (!properties?.name?.[0]) return; + if (!properties || !hasDisplayableName(properties)) return; rows.push({ key: `person-${memberIndex}-${id}-${idx}`, member, diff --git a/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx b/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx index 7a5449ea4..ad5a23a2c 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx @@ -1,14 +1,14 @@ +import { FreeFormMatchCardDataContent } from '@app-builder/components/Screenings/FreeformSearch/FreeformMatchCard'; import { useTranslation } from 'react-i18next'; import { Button, Modal } from 'ui-design-system'; import { Icon } from 'ui-icons'; -import { FreeFormMatchCardDataContent } from '../FreeformSearch/FreeformMatchCard'; export default function ModalPerson({ personId, personName }: { personId: string; personName: string }) { - const { t } = useTranslation(['common']); + const { t } = useTranslation(['common', 'screenings']); return ( - diff --git a/packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx b/packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx index 295b4bdb8..ffaab5b9c 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/ModalSanction.tsx @@ -26,7 +26,7 @@ export function ModalSanction({ sanction }: { sanction: ScreeningSanctionEntity return ( - diff --git a/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts b/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts index 1aeb4fe25..2cf8520b2 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts +++ b/packages/app-builder/src/components/Screenings/MatchCard/match-card-utility-functions.ts @@ -1,4 +1,4 @@ -import { FamilyPersonEntity, FamilyRelativeEntity } from '@app-builder/models/screening'; +import { FamilyPersonEntity, FamilyRelativeEntity, PersonEntity } from '@app-builder/models/screening'; import { formatDateTimeWithoutPresets } from '@app-builder/utils/format'; import { tryCatch } from '@app-builder/utils/tryCatch'; import { Temporal } from 'temporal-polyfill'; @@ -409,13 +409,22 @@ export function mergeAddresses(addressStrings: string[], rawAddressEntities: unk export function getPersonName(entity: FamilyMemberRow | AssociationRow | FamilyPersonEntity | FamilyRelativeEntity) { const { firstName, lastName, name, alias } = entity.properties; - if (firstName?.[0] || lastName?.[0]) return `${firstName?.[0]} ${lastName?.[0]}`.trim(); + if (firstName?.[0] || lastName?.[0]) return [firstName?.[0], lastName?.[0]].filter(Boolean).join(' '); if (name?.[0]) return name[0]; if (alias?.[0]) return alias[0]; return '?'; } +/** + * A person is displayable if it has any identity field the UI can render: + * a caption, or any of the fields getPersonName falls back through. + */ +export function hasDisplayableName(properties: PersonEntity['properties']) { + const { firstName, lastName, name, alias, caption } = properties; + return Boolean(caption || firstName?.[0] || lastName?.[0] || name?.[0] || alias?.[0]); +} + export function splitTextWithEmbeddedDates(value: string): TextSegment[] { const segments: TextSegment[] = []; let lastIndex = 0; diff --git a/packages/app-builder/src/components/Screenings/MatchDetails.tsx b/packages/app-builder/src/components/Screenings/MatchDetails.tsx index 92ec982dc..be28745a6 100644 --- a/packages/app-builder/src/components/Screenings/MatchDetails.tsx +++ b/packages/app-builder/src/components/Screenings/MatchDetails.tsx @@ -1,4 +1,5 @@ import { + type AssociationEntity, type FamilyPersonEntity, type FamilyRelationshipEntry, type FamilyRelativeEntity, @@ -111,8 +112,7 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp associations = entity.properties.associations.filter(({ properties }) => { if (!properties.person) return false; if (properties.person.some((p) => associateIds.has(p.id))) { - const personId = properties.person.find((p) => associateIds.has(p.id))?.id; - if (personId) associateIds.delete(personId); + properties.person.forEach((p) => associateIds.delete(p.id)); return true; } properties.person.forEach((p) => @@ -121,13 +121,27 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp return false; }); - ignoredAssociation.forEach((person) => { - const association = associations?.find(({ properties }) => properties.person?.some((p) => p.id === person.id)); - if (association) { - const relationships = new Set(association.properties.relationship ?? []); - person.relationship.forEach((r) => relationships.add(r)); - association.properties.relationship = Array.from(relationships); - } + const extraRelationshipsByPersonId = new Map>(); + ignoredAssociation.forEach(({ id, relationship }) => { + const existing = extraRelationshipsByPersonId.get(id) ?? new Set(); + relationship.forEach((r) => existing.add(r)); + extraRelationshipsByPersonId.set(id, existing); + }); + + associations = associations?.map((association) => { + const matchingPerson = association.properties.person?.find((p) => extraRelationshipsByPersonId.has(p.id)); + if (!matchingPerson) return association; + + const relationships = new Set(association.properties.relationship ?? []); + extraRelationshipsByPersonId.get(matchingPerson.id)!.forEach((r) => relationships.add(r)); + + return { + ...association, + properties: { + ...association.properties, + relationship: Array.from(relationships), + }, + } as AssociationEntity; }); } diff --git a/packages/app-builder/src/constants/screening-entity.tsx b/packages/app-builder/src/constants/screening-entity.tsx index ee2d13b55..f46ab1dbd 100644 --- a/packages/app-builder/src/constants/screening-entity.tsx +++ b/packages/app-builder/src/constants/screening-entity.tsx @@ -228,7 +228,7 @@ const propertyMetadata: Record = { okpoCode: { type: 'string' }, opencorporatesUrl: { type: 'url' }, passportNumber: { type: 'string', format: 'monospace' }, - phone: { type: 'string' }, + phone: { type: 'string', format: 'phone' }, political: { type: 'string' }, position: { type: 'string' }, previousName: { type: 'string' }, diff --git a/packages/app-builder/src/locales/ar/screenings.json b/packages/app-builder/src/locales/ar/screenings.json index a12143f8b..cdf5e6b0b 100644 --- a/packages/app-builder/src/locales/ar/screenings.json +++ b/packages/app-builder/src/locales/ar/screenings.json @@ -201,6 +201,7 @@ "match.family.unknown_relationship": "غير معروف", "match.membership.no-caption": "بدون تسمية توضيحية", "match.membership.title": "العضويات", + "match.not_available": "غير متوفر", "match.not_reviewable": "غير قابل للمراجعة", "match.score": "النتيجة {{score}}%", "match.similarity": "التشابه {{percent}}%", diff --git a/packages/app-builder/src/locales/en/screenings.json b/packages/app-builder/src/locales/en/screenings.json index e22e8d556..f7d39fe48 100644 --- a/packages/app-builder/src/locales/en/screenings.json +++ b/packages/app-builder/src/locales/en/screenings.json @@ -201,6 +201,7 @@ "match.family.unknown_relationship": "Unknown", "match.membership.no-caption": "No caption", "match.membership.title": "Memberships", + "match.not_available": "Not available", "match.not_reviewable": "Not reviewable", "match.score": "Score {{score}}%", "match.similarity": "Similarity {{percent}}%", diff --git a/packages/app-builder/src/locales/fr/screenings.json b/packages/app-builder/src/locales/fr/screenings.json index 345913fc7..cf24bec59 100644 --- a/packages/app-builder/src/locales/fr/screenings.json +++ b/packages/app-builder/src/locales/fr/screenings.json @@ -201,6 +201,7 @@ "match.family.unknown_relationship": "Relation inconnue", "match.membership.no-caption": "Sans titre", "match.membership.title": "Affiliations", + "match.not_available": "Non disponible", "match.not_reviewable": "Non examinable", "match.score": "Score {{score}}%", "match.similarity": "Similarité {{percent}} %", diff --git a/packages/app-builder/src/queries/screening/freeform-search.ts b/packages/app-builder/src/queries/screening/freeform-search.ts index 3973f6894..43ad2497a 100644 --- a/packages/app-builder/src/queries/screening/freeform-search.ts +++ b/packages/app-builder/src/queries/screening/freeform-search.ts @@ -49,7 +49,11 @@ export const useSaveFreeformSearchMutation = () => { return useMutation({ mutationKey: ['screening', 'save-freeform-search'], mutationFn: async (input: { id: string }): Promise => { - return saveFreeformSearch({ data: input }); + const response = await saveFreeformSearch({ data: input }); + if (!response.success) { + throw new Error('Failed to save freeform search'); + } + return response; }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); diff --git a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx index 483aeb0fb..890ae6bef 100644 --- a/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx +++ b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx @@ -72,27 +72,25 @@ function ScreeningSearchIndexPage() {

    {t('navigation:screening_search')}

    {hasResults && ( - <> - - - {t('screenings:print.open_print_view')} - - } - > - - - - - {searchState.searchId && ( - - )} - + } + > + + + + + )} + {searchState?.searchId && ( + )}
    diff --git a/packages/ui-design-system/src/Modal/Modal.tsx b/packages/ui-design-system/src/Modal/Modal.tsx index 16b270888..13d6fe200 100644 --- a/packages/ui-design-system/src/Modal/Modal.tsx +++ b/packages/ui-design-system/src/Modal/Modal.tsx @@ -28,7 +28,7 @@ const modalContentClassnames = cva( interface ModalContentProps extends Dialog.DialogContentProps, VariantProps {} const ModalContent = forwardRef(function ModalContent( - { className, size = 'small', ...props }, + { className, size = 'full', ...props }, ref, ) { return ( From a994c373060f8d2cb9f3b290d16ca5e3039957e0 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 15 Jun 2026 17:47:58 +0200 Subject: [PATCH 20/23] fix button in sanctions --- .../src/components/Screenings/MatchCard/Sanctions.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx index b99f7b18e..5abf2384f 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Sanctions.tsx @@ -2,7 +2,7 @@ import { IconDot } from '@app-builder/components/Screenings/MatchCard/match-card import { type ScreeningSanctionEntity } from '@app-builder/models/screening'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ExpandableGroupTagLine } from 'ui-design-system'; +import { Button, ExpandableGroupTagLine } from 'ui-design-system'; import { ModalSanction } from './ModalSanction'; const MAX_SANCTIONS = 5; @@ -70,13 +70,9 @@ export function Sanctions({ sanctions }: { sanctions: ScreeningSanctionEntity[] {hiddenCount > 0 && !showAll && (
  • - +
  • )} From 6fec8e6622a6a393743d98211475efb905711488 Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Mon, 15 Jun 2026 17:50:02 +0200 Subject: [PATCH 21/23] fix more button in family & association --- .../components/Screenings/MatchCard/Associations.tsx | 10 +++------- .../components/Screenings/MatchCard/FamilyDetail.tsx | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx index f4e4fead6..af4ea76f9 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx @@ -4,7 +4,7 @@ import { AssociationEntity } from '@app-builder/models/screening'; import { Fragment, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as R from 'remeda'; -import { ExpandableGroupTagLine } from 'ui-design-system'; +import { Button, ExpandableGroupTagLine } from 'ui-design-system'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; @@ -131,13 +131,9 @@ export const Associations = ({ associations }: { associations: AssociationEntity {hiddenCount > 0 && !showAll && (
  • - +
  • )} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx index b32ce7cde..006a612ce 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx @@ -8,7 +8,7 @@ import { import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as R from 'remeda'; -import { cn, ExpandableGroupTagLine } from 'ui-design-system'; +import { Button, cn, ExpandableGroupTagLine } from 'ui-design-system'; import { Icon } from 'ui-icons'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; @@ -154,13 +154,9 @@ export function FamilyDetail({ familyMembers, relation } {hiddenCount > 0 && !showAll && (
  • - +
  • )} From 31787326855406af6c0ca52bfd373a36349a80ca Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Tue, 16 Jun 2026 11:28:01 +0200 Subject: [PATCH 22/23] remove redundant relationship and fix review remarks --- .../FreeformSearch/FreeformMatchCard.tsx | 4 +-- .../FreeformSearch/FreeformSearchForm.tsx | 16 +++++------- .../FreeformSearch/ViewSavedResults.tsx | 8 +++--- .../Screenings/MatchCard/Associations.tsx | 12 +++++---- .../Screenings/MatchCard/FamilyDetail.tsx | 21 ++++++++++----- .../Screenings/MatchCard/ModalPerson.tsx | 2 +- .../match-card-entity-components.tsx | 2 +- .../src/constants/screening-entity.tsx | 2 +- .../src/queries/screening/freeform-search.ts | 26 +++++-------------- .../app-builder/src/server-fns/screenings.ts | 19 +++++++------- packages/ui-icons/src/generated/icon-names.ts | 1 + .../src/generated/icons-svg-sprite.svg | 2 +- .../ui-icons/svgs/icons/external-link.svg | 3 +++ 13 files changed, 58 insertions(+), 60 deletions(-) create mode 100644 packages/ui-icons/svgs/icons/external-link.svg diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx index 6da73284e..bc54a4566 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx @@ -80,8 +80,8 @@ export function FreeFormMatchCardDataContent({ const { t } = useTranslation(screeningsI18n); const enrichedData = useGetEnrichedDataQuery({ entityId }, isOpen); if (enrichedData.isLoading) return ; - if (!enrichedData.data?.success) return
    {t('screenings:match.enriched_data_error')}
    ; - const entity = enrichedData.data.data; + if (enrichedData.isError) return
    {t('screenings:match.enriched_data_error')}
    ; + const entity = enrichedData.data; if (!entity) return
    {t('screenings:match.enriched_data_error')}
    ; return (
    diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx index e1d6c1236..4f9f42a78 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx @@ -115,16 +115,14 @@ const FreeformSearchFormInner: FunctionComponent<{ provider: ScreeningProviders limit: value.limit ?? DEFAULT_LIMIT, }; - try { - const result = await searchMutation.mutateAsync(submitValue); - if (result.success) { - onSearchComplete(result.data, submitValue); - } else { + searchMutation + .mutateAsync(submitValue) + .then((result) => { + onSearchComplete(result, submitValue); + }) + .catch(() => { toast.error(t('common:errors.unknown')); - } - } catch { - toast.error(t('common:errors.unknown')); - } + }); }, }); diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index a72445965..d695e7e1b 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -79,7 +79,7 @@ export const ViewSavedResults = () => { }), ); - const data = query.data?.success ? query.data.data : undefined; + const data = query.data; const items = data?.data ?? []; const hasNextPage = data?.has_next_page ?? false; const limit = (paginationParams.limit ?? 25) as PageSize; @@ -148,7 +148,7 @@ export const ViewSavedResults = () => {
    {t('screenings:freeform_search.saved_results.loading')}
    - ) : query.data?.success === false ? ( + ) : query.isError ? (
    {t('screenings:freeform_search.saved_results.error')}
    @@ -535,9 +535,9 @@ function FilterPill({ function SavedResults({ id }: { id: string }) { const query = useGetFreeformSearchQuery(id); - return query.data?.success ? ( + return query.isSuccess && query.data && query.data.matches ? (
    - {query.data.data.matches?.map((match) => { + {query.data.matches?.map((match) => { return ; })}
    diff --git a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx index af4ea76f9..82961e463 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx @@ -1,10 +1,11 @@ import { StringCodeComponent } from '@app-builder/components/Data/DataVisualisation/DataField'; import { IconDot } from '@app-builder/components/Screenings/MatchCard/match-card-entity-components'; import { AssociationEntity } from '@app-builder/models/screening'; -import { Fragment, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as R from 'remeda'; import { Button, ExpandableGroupTagLine } from 'ui-design-system'; +import { Icon } from 'ui-icons'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; @@ -107,9 +108,10 @@ export const Associations = ({ associations }: { associations: AssociationEntity {association.properties.sourceUrl && association.properties.sourceUrl.length > 0 && (
    {t('screenings:match.family.source.label')}
    -
    +
    +
    )}
    diff --git a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx index 006a612ce..75561bcd9 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx @@ -13,7 +13,7 @@ import { Icon } from 'ui-icons'; import { getFilteredAndSortedTopics } from '../TopicsDisplay'; import { isDisplayableTopic, TopicTag } from '../TopicTag'; import ModalPerson from './ModalPerson'; -import { getPersonName, hasDisplayableName } from './match-card-utility-functions'; +import { cleanUrl, getPersonName, hasDisplayableName } from './match-card-utility-functions'; const MAX_FAMILY_MEMBERS = 5; @@ -35,6 +35,13 @@ export type FamilyMemberRow = { relationshipEntries: FamilyRelationshipEntry[]; }; +function preferFamilyPersonRelationships(entries: FamilyRelationshipEntry[]): FamilyRelationshipEntry[] { + const familyPersonValues = new Set( + entries.filter((entry) => entry.source === 'familyPerson').map((entry) => entry.value), + ); + return entries.filter((entry) => entry.source !== 'familyRelative' || !familyPersonValues.has(entry.value)); +} + function FamilyRelationshipTag({ value, source }: FamilyRelationshipEntry) { const { t } = useTranslation(['screenings']); const label = value @@ -59,9 +66,10 @@ function flattenFamilyMembers( familyMembers.forEach((member, memberIndex) => { const entities = member.properties[relation === 'familyPerson' ? 'relative' : 'person'] as PersonEntity[]; - const relationshipEntries: FamilyRelationshipEntry[] = + const relationshipEntries = preferFamilyPersonRelationships( member.properties.relationships ?? - (member.properties.relationship ?? []).map((value) => ({ value, source: relation })); + (member.properties.relationship ?? []).map((value) => ({ value, source: relation })), + ); entities?.forEach(({ id, properties }, idx) => { if (!properties || !hasDisplayableName(properties)) return; @@ -131,16 +139,17 @@ export function FamilyDetail({ familyMembers, relation } {member.properties.sourceUrl && member.properties.sourceUrl.length > 0 && (
    {t('screenings:match.family.source.label')}
    -
      +
        {member.properties.sourceUrl.map((url, urlIdx) => ( -
      • +
      • + - {url} + {cleanUrl(url)}
      • ))} diff --git a/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx b/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx index ad5a23a2c..e97a0138d 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx @@ -14,7 +14,7 @@ export default function ModalPerson({ personId, personName }: { personId: string {personName} - + diff --git a/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx b/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx index 1fd44e61f..a628c30a5 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/match-card-entity-components.tsx @@ -102,7 +102,7 @@ export function IconDot({ dark, spaced }: { dark?: boolean; spaced?: boolean }) = { passportNumber: { type: 'string', format: 'monospace' }, phone: { type: 'string', format: 'phone' }, political: { type: 'string' }, - position: { type: 'string' }, + position: { type: 'string', format: 'position' }, previousName: { type: 'string' }, program: { type: 'string' }, publisher: { type: 'string' }, diff --git a/packages/app-builder/src/queries/screening/freeform-search.ts b/packages/app-builder/src/queries/screening/freeform-search.ts index 43ad2497a..c48b14097 100644 --- a/packages/app-builder/src/queries/screening/freeform-search.ts +++ b/packages/app-builder/src/queries/screening/freeform-search.ts @@ -13,9 +13,7 @@ import { import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useServerFn } from '@tanstack/react-start'; -type FreeformSearchResponse = - | { success: true; data: { id: string; matches: ScreeningMatchPayload[] } } - | { success: false; error: unknown }; +type FreeformSearchResponse = { id: string; matches: ScreeningMatchPayload[] }; export const useFreeformSearchMutation = () => { const freeformSearch = useServerFn(freeformSearchFn); @@ -40,20 +38,14 @@ export const useFreeformSearchMutation = () => { }); }; -type SaveFreeformSearchResponse = { success: true } | { success: false; error: unknown }; - export const useSaveFreeformSearchMutation = () => { const queryClient = useQueryClient(); const saveFreeformSearch = useServerFn(saveFreeformSearchFn); return useMutation({ mutationKey: ['screening', 'save-freeform-search'], - mutationFn: async (input: { id: string }): Promise => { - const response = await saveFreeformSearch({ data: input }); - if (!response.success) { - throw new Error('Failed to save freeform search'); - } - return response; + mutationFn: async (input: { id: string }): Promise => { + await saveFreeformSearch({ data: input }); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); @@ -61,24 +53,18 @@ export const useSaveFreeformSearchMutation = () => { }); }; -type ListSavedFreeformSearchesResponse = - | { success: true; data: SavedScreeningSearchPage } - | { success: false; error: unknown }; - export const useSavedFreeformSearchesQuery = (filters: SavedScreeningSearchFilters = {}) => { const listSavedSearches = useServerFn(listSavedFreeformSearchesFn); return useQuery({ queryKey: ['screening', 'saved-searches', filters], - queryFn: async (): Promise => { - return listSavedSearches({ data: filters }) as Promise; + queryFn: async (): Promise => { + return listSavedSearches({ data: filters }) as Promise; }, }); }; -type GetFreeformSearchResponse = - | { success: true; data: { id: string; matches: ScreeningMatchPayload[] } } - | { success: false; error: unknown }; +type GetFreeformSearchResponse = { id: string; matches: ScreeningMatchPayload[] }; export const useGetFreeformSearchQuery = (id: string) => { const getFreeformSearch = useServerFn(getFreeformSearchFn); diff --git a/packages/app-builder/src/server-fns/screenings.ts b/packages/app-builder/src/server-fns/screenings.ts index 4c68f5167..d6f052ec5 100644 --- a/packages/app-builder/src/server-fns/screenings.ts +++ b/packages/app-builder/src/server-fns/screenings.ts @@ -189,10 +189,10 @@ export const freeformSearchFn = createServerFn({ method: 'POST' }) .handler(async ({ context, data }) => { try { const result = await context.authInfo.screening.freeformSearch(data); - return { success: true as const, data: result }; + return result; } catch (error) { console.error(`Freeform search error (${data})`, error); - return { success: false as const, error: 'Freeform search failed' }; + throw new Error('Freeform search failed'); } }); @@ -202,9 +202,8 @@ export const saveFreeformSearchFn = createServerFn({ method: 'POST' }) .handler(async ({ context, data }) => { try { await context.authInfo.screening.saveFreeformSearch(data); - return { success: true as const }; } catch { - return { success: false as const, error: 'Save freeform search failed' }; + throw new Error('Save freeform search failed'); } }); @@ -214,9 +213,9 @@ export const listSavedFreeformSearchesFn = createServerFn({ method: 'GET' }) .handler(async ({ context, data }) => { try { const page = await context.authInfo.screening.listSavedScreeningSearches(data); - return { success: true as const, data: page }; + return page; } catch { - return { success: false as const, error: 'List saved searches failed' }; + throw new Error('List saved searches failed'); } }); @@ -226,9 +225,9 @@ export const getFreeformSearchFn = createServerFn({ method: 'GET' }) .handler(async ({ context, data }) => { try { const result = await context.authInfo.screening.getFreeformSearch(data); - return { success: true as const, data: result }; + return result; } catch { - return { success: false as const, error: 'Get freeform search failed' }; + throw new Error('Get freeform search failed'); } }); @@ -238,9 +237,9 @@ export const getEnrichedDataFn = createServerFn({ method: 'GET' }) .handler(async ({ context, data }) => { try { const result = await context.authInfo.screening.enrichedData(data); - return { success: true as const, data: result }; + return result; } catch { - return { success: false as const, error: 'Enriched data failed' }; + throw new Error('Enriched data failed'); } }); diff --git a/packages/ui-icons/src/generated/icon-names.ts b/packages/ui-icons/src/generated/icon-names.ts index 9141ce395..af9204d7c 100644 --- a/packages/ui-icons/src/generated/icon-names.ts +++ b/packages/ui-icons/src/generated/icon-names.ts @@ -54,6 +54,7 @@ export const iconNames = [ 'empty-flag', 'enum', 'error', + 'external-link', 'eye-slash', 'eye', 'field', diff --git a/packages/ui-icons/src/generated/icons-svg-sprite.svg b/packages/ui-icons/src/generated/icons-svg-sprite.svg index 642f16038..ffa2308f9 100644 --- a/packages/ui-icons/src/generated/icons-svg-sprite.svg +++ b/packages/ui-icons/src/generated/icons-svg-sprite.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/ui-icons/svgs/icons/external-link.svg b/packages/ui-icons/svgs/icons/external-link.svg new file mode 100644 index 000000000..8e47cdbab --- /dev/null +++ b/packages/ui-icons/svgs/icons/external-link.svg @@ -0,0 +1,3 @@ + + + From 03f525f0b30720221a551ced449737e301a1368f Mon Sep 17 00:00:00 2001 From: William Schlegel Date: Tue, 16 Jun 2026 11:45:57 +0200 Subject: [PATCH 23/23] fix conflicts & ts errors --- .../components/Screenings/FreeformSearch/ViewSavedResults.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index d695e7e1b..4eec4a617 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -349,7 +349,7 @@ function PeriodFilter({ }} > -