diff --git a/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx b/packages/app-builder/src/components/Data/DataVisualisation/DataField.tsx index 9a85170c81..cff7a39a73 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'; @@ -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 {value}; + return StringCodeComponent({ value }); +} + +export function StringCodeComponent({ value, children }: { value?: string; children?: ReactNode }) { + return {value ?? children ?? '-'}; } 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 ( @@ -360,19 +390,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() { @@ -399,12 +431,26 @@ function StringCurrency() { function DateDatetime() { const value = useStringValue(); + if (!value) return ; + return DateDatetimeComponent({ value }); +} + +export function DateDatetimeComponent({ + value, + withTime = true, + monospaced = false, +}: { + value: string; + withTime?: boolean; + monospaced?: 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 0de44fc8c4..ee78946647 100644 --- a/packages/app-builder/src/components/Screenings/EntityProperties.tsx +++ b/packages/app-builder/src/components/Screenings/EntityProperties.tsx @@ -1,6 +1,9 @@ import { + BirthdDateAverage, createPropertyTransformer, getSanctionEntityProperties, + IconDot, + isPropertyListed, type PropertyForSchema, type ScreeningEntityProperty, } from '@app-builder/constants/screening-entity'; @@ -8,7 +11,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 { screeningsI18n } from './screenings-i18n'; export function EntityProperties({ @@ -63,23 +65,25 @@ 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 ? null : ·} + {i === values.length - 1 || isPropertyListed(property) ? null : } ))} {restItemsCount > 0 ? ( <> - · + {isPropertyListed(property) ? null : } ) : null} - + ) : ( not available )} - +
); })} @@ -103,3 +107,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/components/Screenings/FreeformSearch/FreeformMatchCard.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformMatchCard.tsx index 8cf408c207..f4c9eea067 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,10 +44,12 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM
- +
{entitySchema === 'person' && entity.datasets?.length ? ( -
+
{t('screenings:match.datasets.title')}
    @@ -56,7 +62,7 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM
) : null} - +
@@ -65,7 +71,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 +90,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/FreeformSearch/FreeformSearchForm.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/FreeformSearchForm.tsx index d6e9b09fd3..0ee23c402d 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 5ed8fc9965..ce11bd99bd 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/components/Screenings/FreeformSearch/SaveSearch.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/SaveSearch.tsx index 3b2b567cb2..a356dcb0fb 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')} - + */} diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx index b562a213ff..c7360674b6 100644 --- a/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx +++ b/packages/app-builder/src/components/Screenings/FreeformSearch/ViewSavedResults.tsx @@ -1,27 +1,24 @@ +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 { 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'; +import { omitUndefined } from '@app-builder/utils/omit-undefined'; import { useDebouncedCallbackRef } from '@marble/shared'; -import { type ReactNode, useMemo, useState } from 'react'; +import { ScreeningConfigBodySectionDto } from 'marble-api'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Temporal } from 'temporal-polyfill'; -import { - Avatar, - Button, - Collapsible, - cn, - ExpandableGroupTagLine, - Input, - MenuCommand, - Separator, - Tag, -} from 'ui-design-system'; +import { Avatar, Button, Collapsible, cn, 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 +31,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 +51,49 @@ 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 [paginationParams, setPaginationParams] = useState({ limit: 25 }); + + const filterValues = useMemo( + () => + omitUndefined({ + userId: ownerId, + isSaved: true, + }), + [ownerId], + ); - const applyName = useDebouncedCallbackRef((value: string) => { - setName(value); - setPage(1); + const applyName = useDebouncedCallbackRef((_value: string) => { + // setName(value); + setPaginationParams((prev) => ({ limit: prev.limit ?? 25 })); }, 300); - const { fromDate, toDate } = useMemo(() => toIsoRange(dateRange), [dateRange]); + const resetPagination = () => { + setPaginationParams((prev) => ({ limit: prev.limit ?? 25 })); + }; - const query = useSavedFreeformSearchesQuery({ - name: name || undefined, - fromDate, - toDate, - ownerId, - page, - limit, - }); + // const { fromDate, toDate } = useMemo(() => toIsoRange(dateRange), [dateRange]); + const query = useSavedFreeformSearchesQuery( + omitUndefined({ + ...filterValues, + ...paginationParams, + }), + ); const data = query.data?.success ? query.data.data : undefined; - const items = data?.items ?? []; - const total = data?.total ?? 0; + const items = data?.data ?? []; + const hasNextPage = data?.has_next_page ?? false; + const limit = (paginationParams.limit ?? 25) as PageSize; - 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; + const paginationItems = useMemo(() => items.map((item) => ({ id: item.id, createdAt: item.created_at })), [items]); + + const paginationState = usePaginationsButton({ + filterValues, + items: paginationItems, + initialOffsetId: paginationParams.offsetId, + }); return ( <> @@ -121,14 +131,14 @@ export const ViewSavedResults = () => { value={dateRange} onChange={(v) => { setDateRange(v); - setPage(1); + resetPagination(); }} /> { setOwnerId(v); - setPage(1); + resetPagination(); }} />
@@ -158,19 +168,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} + /> +
@@ -183,115 +199,111 @@ function SavedSearchRow({ search }: { search: SavedScreeningSearch }) { const language = useFormatLanguage(); const { currentUser } = useOrganizationDetails(); const { getOrgUserById } = useOrganizationUsers(); - const owner = getOrgUserById(search.ownerId); - const isYou = currentUser.actorIdentity.userId === search.ownerId; + const owner = search.user_id ? getOrgUserById(search.user_id) : undefined; + const isYou = currentUser.actorIdentity.userId === search.user_id; return (
- {search.name} + {search.search_input.query?.['name']?.join(', ')} +
+
{owner ? ( - + {`${owner.firstName} ${owner.lastName}`.trim()} {isYou ? ` (${t('screenings:freeform_search.saved_results.you')})` : null} ) : ( - {search.ownerId} + {search.user_id} + )} + + {formatDateTimeWithoutPresets(search.created_at, { language, dateStyle: 'short' })} + + {search.is_saved ? ( + {search?.matches?.length ?? 0} + ) : ( + {t('screenings:freeform_search.saved_results.not_saved')} )} - -
-
- - {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 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 InputTags({ input }: { input: SavedScreeningSearch['inputs'] }) { +function TopicTag({ topics }: { topics: NonNullable }) { 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; - + if (topics['livness'] && topics['livness'].length === 1) + return ( + + {t('freeform_search.global.liveness')} + + ); return ( - ( - - +{overflow} - - )} - lessButton={(onCollapse) => ( - - + <> + {Object.entries(topics).map(([key, value]) => ( + + {key}:{value?.length ?? 0} - )} - /> + ))} + ); } -function InputTag({ label, values }: { label?: string; values: string | string[] }) { - if (values.length === 0) return null; +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 ( - - {label ? {label} : null} - {Array.isArray(values) ? ( - - {values.slice(0, 2).join(', ')} - {values.length > 2 ? {` +${values.length - 2}`} : null} - - ) : ( - {values} +
+ {type !== 'Thing' && ( + + {t(`screenings:entity.schema.${type.toLocaleLowerCase()}`)} + )} - + {entityTypeFields.map((field) => + query.query[field] ? ( + + {t(`screenings:entity.property.${field}.short`)}:{query.query[field].join(', ')} + + ) : null, + )} +
); } @@ -337,7 +349,7 @@ function PeriodFilter({ }} > - - ); - })} - -
- - {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 ( + + ); + })}
); } @@ -559,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/components/Screenings/MatchCard/Associations.tsx b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx index 39de45ad6c..a40045c7db 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/Associations.tsx @@ -1,88 +1,143 @@ +import { StringCodeComponent } from '@app-builder/components/Data/DataVisualisation/DataField'; +import { IconDot } from '@app-builder/constants/screening-entity'; import { AssociationEntity } from '@app-builder/models/screening'; +import { 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 { TopicTag } from '../TopicTag'; +import { isDisplayableTopic, TopicTag } from '../TopicTag'; +import ModalPerson from './ModalPerson'; -const MAX_ASSOCIATIONS = 50; +const MAX_ASSOCIATIONS = 5; + +type AssociationRow = { + key: string; + association: AssociationEntity; + id: string; + properties: NonNullable[number]['properties']; +}; + +function flattenAssociations(associations: AssociationEntity[]): AssociationRow[] { + const rows: AssociationRow[] = []; + + associations.forEach((association, associationIndex) => { + association.properties.person?.forEach((person, idx) => { + if (!person.properties?.name?.[0]) return; + rows.push({ + key: `person-${associationIndex}-${person.id}-${idx}`, + association, + id: person.id, + properties: person.properties, + }); + }); + }); + + return rows; +} export const Associations = ({ associations }: { associations: AssociationEntity[] | undefined }) => { - const { t } = useTranslation(['screenings']); + const { t } = useTranslation(['screenings', 'common']); + const [showAll, setShowAll] = useState(false); + + const rows = useMemo(() => (associations ? flattenAssociations(associations) : []), [associations]); + const hiddenCount = Math.max(0, rows.length - MAX_ASSOCIATIONS); + const visibleRows = showAll ? rows : rows.slice(0, MAX_ASSOCIATIONS); - if (!associations || associations.length === 0) return null; + if (rows.length === 0) return null; return ( -
- {associations.slice(0, MAX_ASSOCIATIONS).map((association, associationIndex) => { - return association.properties.person?.map((person: any, idx: number) => { - const { id, properties } = person; - if (!properties?.name?.[0]) return null; - const rel = - association.properties.relationship - ?.map((relation: string) => - t(`screenings:relation.${R.toCamelCase(relation)}.label`, { - defaultValue: relation, - }), - ) - .join(' · ') ?? t('screenings:match.family.unknown_relationship'); +
    + {visibleRows.map((row, rowIndex) => { + const { key, association, id, properties } = row; + const isFirstElement = rowIndex === 0; - const isFirstElement = associationIndex === 0 && idx === 0; + const rel = association.properties.relationship?.map((relation: string) => + t(`screenings:relation.${R.toCamelCase(relation)}.label`, { + defaultValue: relation, + }), + ); - return ( -
    -
    - {isFirstElement &&
    {t('screenings:match.associations.title')}
    } -
    -
    -
    - {properties.caption?.length > 0 ? ( -
    {properties.caption}
    - ) : ( -
    - {properties.alias?.[0] ?? properties.name?.[0]} -
    - )} -
    {rel}
    - {properties.topics?.length ? ( -
    - {getFilteredAndSortedTopics(properties.topics).map((topic: string) => ( - - ))} -
    - ) : null} - {association.properties.sourceUrl && association.properties.sourceUrl.length > 0 && ( -
    -
    {t('screenings:match.family.source.label')}
    -
      - {association.properties.sourceUrl.map((url, urlIdx) => ( -
    • - - {url} - -
    • - ))} -
    -
    - )} -
    + const tags = properties.topics?.length + ? getFilteredAndSortedTopics(properties.topics) + .filter(isDisplayableTopic) + .map((topic: string) => ) + : []; + + const expandableItems = [ + , + properties.caption?.length > 0 ? ( + + {properties.caption} + + ) : ( + + {properties.name?.[0] ?? properties.alias?.[0]} + + ), + , + rel ? ( + + {rel?.map((r, index) => ( + + {r} + {index < rel.length - 1 ? : null} + + ))} + + ) : ( + + ), + , + ...tags, + ]; + + return ( +
  • +
    + {isFirstElement &&
    {t('screenings:match.associations.title')}
    } +
    +
    +
    + +
    + + {association.properties.sourceUrl && association.properties.sourceUrl.length > 0 && ( + +
    {t('screenings:match.family.source.label')}
    +
      + {association.properties.sourceUrl.map((url, urlIdx) => ( +
    • + + {url} + +
    • + ))} +
    +
    + )}
    - ); - }); +
  • + ); })} - {associations.length > MAX_ASSOCIATIONS && ( -
    + {hiddenCount > 0 && !showAll && ( +
  • -
    - {t('screenings:match.associations.more', { count: associations.length - MAX_ASSOCIATIONS })} -
    -
  • + + )} -
    +
); }; diff --git a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx index c9bca938ef..763dc91ecf 100644 --- a/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx +++ b/packages/app-builder/src/components/Screenings/MatchCard/FamilyDetail.tsx @@ -1,10 +1,20 @@ -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'; +import ModalPerson from './ModalPerson'; + +const MAX_FAMILY_MEMBERS = 5; export type RelationType = 'familyPerson' | 'familyRelative'; export type RelationEntity = T extends 'familyPerson' @@ -16,77 +26,151 @@ 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; +} + +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); + + 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 2e2e5de190..53d3a0e4f5 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/MatchCard/ModalPerson.tsx b/packages/app-builder/src/components/Screenings/MatchCard/ModalPerson.tsx new file mode 100644 index 0000000000..7a5449ea45 --- /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/components/Screenings/MatchDetails.tsx b/packages/app-builder/src/components/Screenings/MatchDetails.tsx index 1c60d9f657..b4ca1941f2 100644 --- a/packages/app-builder/src/components/Screenings/MatchDetails.tsx +++ b/packages/app-builder/src/components/Screenings/MatchDetails.tsx @@ -1,10 +1,15 @@ 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'; import { Icon } from 'ui-icons'; - import { EntityProperties } from './EntityProperties'; import { Associations } from './MatchCard/Associations'; import { FamilyDetail } from './MatchCard/FamilyDetail'; @@ -32,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); @@ -41,16 +60,109 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp const deduplicatedEntity = useMemo(() => { if (entity.schema !== 'Person') return entity; - const familyPersonIds = new Set(entity.properties?.familyPerson?.flatMap(({ properties }) => properties?.person)); - if (!familyPersonIds.size) return entity; + 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; + }); + + 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) ?? []), + ); + 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, - familyRelative: entity.properties.familyRelative?.filter( - ({ properties }) => !properties.relative?.some((relativeId) => familyPersonIds.has(relativeId)), - ), + familyPerson, + familyRelative, + associations, }, }; }, [entity]); @@ -108,7 +220,7 @@ export function MatchDetails({ entity, before, highlightText }: MatchDetailsProp ) : null} - + {entity.schema === 'Person' && deduplicatedEntity.properties?.['familyPerson']?.length ? ( diff --git a/packages/app-builder/src/components/Screenings/TopicTag.tsx b/packages/app-builder/src/components/Screenings/TopicTag.tsx index f354ead3f3..e451fc48af 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/components/Spinner.tsx b/packages/app-builder/src/components/Spinner.tsx index af26340124..4b5ea98d6a 100644 --- a/packages/app-builder/src/components/Spinner.tsx +++ b/packages/app-builder/src/components/Spinner.tsx @@ -10,8 +10,8 @@ interface SpinnerProps { export function Spinner({ className }: SpinnerProps) { const { t } = useTranslation(['common']); return ( -
-
+ {t('common:loading')} -
+ ); } diff --git a/packages/app-builder/src/constants/screening-entity.tsx b/packages/app-builder/src/constants/screening-entity.tsx index 828e970ae7..271f1e1037 100644 --- a/packages/app-builder/src/constants/screening-entity.tsx +++ b/packages/app-builder/src/constants/screening-entity.tsx @@ -1,6 +1,51 @@ +import { + DateBirthdateComponent, + DateDatetimeComponent, + 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 { screeningsI18n } from '@app-builder/components/Screenings/screenings-i18n'; +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'; +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 type PropertyDataType = 'string' | 'country' | 'url' | 'date' | 'wikidataId'; export type PropertyForSchema< @@ -140,49 +185,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' }, @@ -195,7 +257,7 @@ const propertyMetadata = { sector: { type: 'string' }, socialSecurityNumber: { type: 'string' }, sourceUrl: { type: 'url' }, - status: { type: 'string' }, + status: { type: 'string', format: 'monospace' }, summary: { type: 'string' }, swiftBic: { type: 'string' }, taxNumber: { type: 'string' }, @@ -208,13 +270,16 @@ 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' }, +}; + +// list of properties that are displayed in a list, not inline +export const propertyMetadataList: Array = ['address']; export function getSanctionEntityProperties(schema: OpenSanctionEntitySchema) { let currentSchema: OpenSanctionEntitySchema | null = schema; @@ -228,50 +293,308 @@ 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, v, ctx.highlightText)}
+ ) : ( +
+ ), + ) + : formatedValue(format, value, ctx.highlightText); case 'url': return ( - - {value} + + {cleanUrl(value)} ); - case 'country': - try { - return {intlCountry.of(value.toUpperCase())}; - } catch { - return value.toUpperCase(); - } - case 'date': - return ; + 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(); + return match(format) + .with('monospace', () => StringCodeComponent({ value })) + .with('date', () => ( + + )) + .with('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(); +} + +// 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); + + 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, + )} + + ); +} + +// 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 5904753bb0..cef4e94afe 100644 --- a/packages/app-builder/src/locales/ar/screenings.json +++ b/packages/app-builder/src/locales/ar/screenings.json @@ -12,6 +12,9 @@ "entity.property.authority": "الجهة", "entity.property.authorityId": "المعرّف الصادر عن الجهة", "entity.property.birthDate": "تاريخ الميلاد", + "entity.property.birthDate.approximative_age.between_dates": "(بين {{min}} و{{max}})", + "entity.property.birthDate.approximative_age.between_same_year": "(بين {{min}} و{{max}}، {{year}})", + "entity.property.birthDate.approximative_age.between_years": "(بين {{min}} و{{max}})", "entity.property.birthDate.format": "تاريخ الميلاد (YYYY أو YYYY-MM-DD)", "entity.property.birthDate.short": "تاريخ الميلاد", "entity.property.birthPlace": "مكان الميلاد", @@ -155,6 +158,7 @@ "freeform_search.saved_results.entity": "كيان", "freeform_search.saved_results.error": "فشل تحميل عمليات البحث المحفوظة", "freeform_search.saved_results.loading": "جارٍ التحميل…", + "freeform_search.saved_results.not_saved": "غير محفوظ", "freeform_search.saved_results.range": "من {{start}} إلى {{end}}", "freeform_search.saved_results.results_per_page": "النتائج لكل صفحة", "freeform_search.saved_results.search_placeholder": "ابحث عن نتيجة...", diff --git a/packages/app-builder/src/locales/en/screenings.json b/packages/app-builder/src/locales/en/screenings.json index 751c16e1e5..32914311cc 100644 --- a/packages/app-builder/src/locales/en/screenings.json +++ b/packages/app-builder/src/locales/en/screenings.json @@ -12,6 +12,9 @@ "entity.property.authority": "Authority", "entity.property.authorityId": "Authority-issued Identifier", "entity.property.birthDate": "Birth date", + "entity.property.birthDate.approximative_age.between_dates": "(between {{min}} and {{max}})", + "entity.property.birthDate.approximative_age.between_same_year": "(between {{min}} and {{max}}, {{year}})", + "entity.property.birthDate.approximative_age.between_years": "(between {{min}} and {{max}})", "entity.property.birthDate.format": "Birth date (YYYY or YYYY-MM-DD)", "entity.property.birthDate.short": "Birth", "entity.property.birthPlace": "Birth place", @@ -155,6 +158,7 @@ "freeform_search.saved_results.entity": "Ent.", "freeform_search.saved_results.error": "Failed to load saved searches", "freeform_search.saved_results.loading": "Loading…", + "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.search_placeholder": "Search for a result...", diff --git a/packages/app-builder/src/locales/fr/screenings.json b/packages/app-builder/src/locales/fr/screenings.json index 457b278da8..dcd59d10d9 100644 --- a/packages/app-builder/src/locales/fr/screenings.json +++ b/packages/app-builder/src/locales/fr/screenings.json @@ -12,6 +12,9 @@ "entity.property.authority": "Autorité", "entity.property.authorityId": "Identifiant officiel", "entity.property.birthDate": "Date de naissance", + "entity.property.birthDate.approximative_age.between_dates": "(entre le {{min}} et le {{max}})", + "entity.property.birthDate.approximative_age.between_same_year": "(entre le {{min}} et le {{max}} {{year}})", + "entity.property.birthDate.approximative_age.between_years": "(entre {{min}} et {{max}})", "entity.property.birthDate.format": "Date de naissance (AAAA ou AAAA-MM-JJ)", "entity.property.birthDate.short": "Naiss.", "entity.property.birthPlace": "Lieu de naissance", @@ -155,6 +158,7 @@ "freeform_search.saved_results.entity": "Ent.", "freeform_search.saved_results.error": "Échec du chargement des recherches enregistrées", "freeform_search.saved_results.loading": "Chargement…", + "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.search_placeholder": "Rechercher un résultat...", diff --git a/packages/app-builder/src/models/screening.ts b/packages/app-builder/src/models/screening.ts index db54a83d0d..9fe7467a2e 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, @@ -92,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: { @@ -101,6 +109,7 @@ export type FamilyPersonEntity = OpenSanctionEntity & { sourceUrl?: string[]; startDate?: string[]; relationship?: string[]; + relationships?: FamilyRelationshipEntry[]; } & Record; }; @@ -113,6 +122,7 @@ export type FamilyRelativeEntity = OpenSanctionEntity & { sourceUrl?: string[]; startDate?: string[]; relationship?: string[]; + relationships?: FamilyRelationshipEntry[]; } & Record; }; @@ -716,27 +726,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 3dc54b0be7..3973f6894f 100644 --- a/packages/app-builder/src/queries/screening/freeform-search.ts +++ b/packages/app-builder/src/queries/screening/freeform-search.ts @@ -1,24 +1,25 @@ import { - type SavedScreeningSearch, type SavedScreeningSearchFilters, type SavedScreeningSearchPage, type ScreeningMatchPayload, } from '@app-builder/models/screening'; import { - deleteSavedFreeformSearchFn, type FreeformSearchInput, freeformSearchFn, + getFreeformSearchFn, listSavedFreeformSearchesFn, - type SaveFreeformSearchInput, saveFreeformSearchFn, } from '@app-builder/server-fns/screenings'; import { useMutation, useQuery, useQueryClient } 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); + const queryClient = useQueryClient(); return useMutation({ mutationKey: ['screening', 'freeform-search'], @@ -33,22 +34,25 @@ export const useFreeformSearchMutation = () => { : input.datasets; return freeformSearch({ data: { ...input, datasets } }) as Promise; }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); + }, }); }; -type SaveFreeformSearchResponse = { success: true; data: SavedScreeningSearch } | { success: false; error: unknown }; +type SaveFreeformSearchResponse = { success: true } | { success: false; error: unknown }; export const useSaveFreeformSearchMutation = () => { - const saveSearch = useServerFn(saveFreeformSearchFn); const queryClient = useQueryClient(); + const saveFreeformSearch = useServerFn(saveFreeformSearchFn); return useMutation({ mutationKey: ['screening', 'save-freeform-search'], - mutationFn: async (input: SaveFreeformSearchInput): Promise => { - return saveSearch({ data: input }) as Promise; + mutationFn: async (input: { id: string }): Promise => { + return saveFreeformSearch({ data: input }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); + void queryClient.invalidateQueries({ queryKey: ['screening', 'saved-searches'] }); }, }); }; @@ -68,19 +72,16 @@ export const useSavedFreeformSearchesQuery = (filters: SavedScreeningSearchFilte }); }; -type DeleteSavedFreeformSearchResponse = { success: true } | { success: false; error: unknown }; - -export const useDeleteSavedFreeformSearchMutation = () => { - const deleteSearch = useServerFn(deleteSavedFreeformSearchFn); - const queryClient = useQueryClient(); +type GetFreeformSearchResponse = + | { success: true; data: { id: string; matches: ScreeningMatchPayload[] } } + | { success: false; error: unknown }; - 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'] }); +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 eee093130c..99bc1986a2 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, @@ -54,23 +52,15 @@ export interface ScreeningRepository { datasets?: string[]; threshold?: number; limit?: number; - }): Promise; + }): 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; - saveScreeningSearch(args: { - name: string; - inputs: SavedScreeningSearchInputs; - results: ScreeningMatchPayload[]; - }): Promise; listSavedScreeningSearches(filters: SavedScreeningSearchFilters): Promise; - deleteSavedScreeningSearch(args: { id: string }): Promise; + getFreeformSearch(args: { id: string }): Promise<{ id: string; matches: ScreeningMatchPayload[] }>; } -// 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 }) => { @@ -164,48 +154,34 @@ 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)), + }; + }, + saveFreeformSearch: async ({ id }) => { + await marbleCoreApiClient.saveFreeformSearch(id); }, 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 }; + listSavedScreeningSearches: async ({ isSaved, userId, apiKeyId, offsetId, limit, order }) => { + return await marbleCoreApiClient.listFreeformSearches({ + savedOnly: isSaved, + userId, + apiKeyId, + offsetId, + limit, + order, + }); }, - deleteSavedScreeningSearch: async ({ id }) => { - savedScreeningSearches.delete(id); + 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/routes/_app/_builder/screening-search/index.tsx b/packages/app-builder/src/routes/_app/_builder/screening-search/index.tsx index ef1d4eb0e3..40e4492157 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 { 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,10 +86,15 @@ 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 a31c1e59da..68a4188d1b 100644 --- a/packages/app-builder/src/server-fns/screenings.ts +++ b/packages/app-builder/src/server-fns/screenings.ts @@ -1,13 +1,6 @@ -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, -} 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'; @@ -109,26 +102,19 @@ 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(), 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; @@ -207,24 +193,21 @@ 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[] }; - } catch { + const result = await context.authInfo.screening.freeformSearch(data); + return { success: true as const, data: result }; + } 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(saveFreeformSearchSchema) + .inputValidator(z.object({ id: z.uuid() })) .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 }; + await context.authInfo.screening.saveFreeformSearch(data); + return { success: true as const }; } catch { return { success: false as const, error: 'Save freeform search failed' }; } @@ -236,21 +219,21 @@ 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 as SavedScreeningSearchPage }; + return { success: true as const, data: page }; } catch { return { success: false as const, error: 'List saved searches failed' }; } }); -export const deleteSavedFreeformSearchFn = createServerFn({ method: 'POST' }) +export const getFreeformSearchFn = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) - .inputValidator(z.object({ id: z.string() })) + .inputValidator(z.object({ id: z.uuid() })) .handler(async ({ context, data }) => { try { - await context.authInfo.screening.deleteSavedScreeningSearch({ id: data.id }); - return { success: true as const }; + const result = await context.authInfo.screening.getFreeformSearch(data); + return { success: true as const, data: result }; } catch { - return { success: false as const, error: 'Delete saved search failed' }; + return { success: false as const, error: 'Get freeform search failed' }; } }); @@ -260,7 +243,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' }; } diff --git a/packages/marble-api/openapis/marblecore-api.yaml b/packages/marble-api/openapis/marblecore-api.yaml index 3183022540..484523508b 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/_schemas.yml b/packages/marble-api/openapis/marblecore-api/_schemas.yml index 7b4d8604f7..b8a47d0545 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/openapis/marblecore-api/screenings.yml b/packages/marble-api/openapis/marblecore-api/screenings.yml index 4bbe6137d2..0328a436f7 100644 --- a/packages/marble-api/openapis/marblecore-api/screenings.yml +++ b/packages/marble-api/openapis/marblecore-api/screenings.yml @@ -354,13 +354,113 @@ $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 + 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: 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: saved_only + description: If true, only return searches for which results were saved + in: query + required: false + schema: + type: boolean + 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' +/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: @@ -922,9 +1022,6 @@ components: required: - query properties: - screening_id: - type: string - format: uuid query: $ref: '#/components/schemas/RefineQueryDto' datasets: @@ -935,6 +1032,83 @@ 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/ScreeningMatchPayloadDto' + ScreeningFreeformSearchDto: + type: object + required: + - id + - created_at + - search_input + - search_config + - is_saved + 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' + is_saved: + type: boolean + matches: + type: array + items: + $ref: '#/components/schemas/ScreeningMatchPayloadDto' + 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 76e40d9dc0..f687f3c9af 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -1146,6 +1146,27 @@ 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; + is_saved: boolean; + matches?: ScreeningMatchPayloadDto[]; +}; export type OpenSanctionsUpstreamDatasetFreshnessDto = { version: string; name: string; @@ -4118,7 +4139,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?: { @@ -4149,7 +4169,10 @@ export function freeformSearch(body?: { } = {}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: ScreeningMatchDto[]; + data: { + id: string; + matches: ScreeningMatchPayloadDto[]; + }; }>(`/screenings/freeform-search${QS.query(QS.explode({ limit }))}`, oazapfts.json({ @@ -4158,6 +4181,83 @@ export function freeformSearch(body?: { body }))); } +/** + * List past freeform searches + */ +export function listFreeformSearches({ limit, offsetId, order, userId, apiKeyId, savedOnly }: { + limit?: number; + offsetId?: string; + order?: "ASC" | "DESC"; + userId?: string; + apiKeyId?: string; + savedOnly?: 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, + user_id: userId, + api_key_id: apiKeyId, + 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 */ diff --git a/packages/ui-design-system/src/ExpandableGroupTagLine/ExpandableGroupTagLine.spec.tsx b/packages/ui-design-system/src/ExpandableGroupTagLine/ExpandableGroupTagLine.spec.tsx index 8dbf7c4777..863b08521b 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/Modal/Modal.tsx b/packages/ui-design-system/src/Modal/Modal.tsx index c1382ae8c3..16b270888b 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', + }, }, ); diff --git a/packages/ui-design-system/src/SelectCountry/SelectCountry.tsx b/packages/ui-design-system/src/SelectCountry/SelectCountry.tsx index ac42c22fac..119a8db8cf 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(), + ); +} diff --git a/packages/ui-icons/src/generated/icon-names.ts b/packages/ui-icons/src/generated/icon-names.ts index 5e0e9f0b6c..9141ce395d 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 6b437c299d..642f160381 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 0000000000..c48d0b9314 --- /dev/null +++ b/packages/ui-icons/svgs/icons/dot.svg @@ -0,0 +1,3 @@ + + +