Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -245,18 +245,30 @@ function EmptyValue({ className }: { className?: string }) {
function StringMain() {
const value = useStringValue();
if (!value) return <EmptyValue />;
return <StringMainComponent value={value} />;
}

export function StringMainComponent({ value }: { value: string }) {
return <span className="font-semibold">{value}</span>;
}

function StringCode() {
const value = useStringValue();
if (!value) return <EmptyValue />;
return <span className={codeClassName}>{value}</span>;
return StringCodeComponent({ value });
}

export function StringCodeComponent({ value, children }: { value?: string; children?: ReactNode }) {
return <span className={codeClassName}>{value ?? children ?? '-'}</span>;
}

function StringEmail() {
const value = useStringValue();
if (!value) return <EmptyValue />;
return StringEmailComponent({ value });
}

export function StringEmailComponent({ value }: { value: string }) {
const isValid = z.email().safeParse(value).success;
if (!isValid) return <span>{value}</span>;
return (
Expand All @@ -269,6 +281,10 @@ function StringEmail() {
function StringPhone() {
const value = useStringValue();
if (!value) return <EmptyValue />;
return StringPhoneComponent({ value });
}

export function StringPhoneComponent({ value }: { value: string }) {
const phone = parsePhoneNumber(value);
const strPhone = phone ? phone.formatInternational() : value;
if (phone) {
Expand All @@ -290,22 +306,36 @@ function StringCity() {

function StringCountry() {
const value = useStringValue();
const language = useFormatLanguage();
if (!value) return <EmptyValue />;
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 <span>{value}</span>;
const country = result.value;
return (
<span className="inline-flex items-center gap-1">
<span>{country.flag}</span>
<span>{formatCountryName(country.isoAlpha2, language)}</span>
{withCountryName && <span>{formatCountryName(country.isoAlpha2, language)}</span>}
</span>
);
}

function StringLink() {
const value = useStringValue();
if (!value) return <EmptyValue />;
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 <span>{value}</span>;
return (
Expand Down Expand Up @@ -360,19 +390,21 @@ function StringFree() {

function DateBirthdate() {
const value = useStringValue();
if (!value) return <EmptyValue />;
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 (
<span className="inline-flex items-center gap-1">
<span className="text-grey-secondary text-xs">{age}</span>
<span className={cn(codeClassName, 'text-sm')}>{formatDateTime(date, { dateStyle: 'short' })}</span>
</span>
);
}
return <EmptyValue />;
const date = new Date(value);
const age = formatAge(date, language);
return (
<span className="inline-flex items-center gap-1">
<span className="text-grey-secondary text-xs">{age}</span>
<span className={cn(codeClassName, 'text-sm')}>{formatDateTime(date, { dateStyle: 'short' })}</span>
</span>
);
}

function StringIban() {
Expand All @@ -399,12 +431,26 @@ function StringCurrency() {

function DateDatetime() {
const value = useStringValue();
if (!value) return <EmptyValue />;
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 <span>{formatDateTime(date, { dateStyle: 'short', timeStyle: 'short' })}</span>;
}
return <EmptyValue />;
const date = new Date(value);
return (
<span className={cn(monospaced && codeClassName)}>
{formatDateTime(date, { dateStyle: 'short', timeStyle: withTime ? 'short' : undefined })}
</span>
);
}

function DataGpsCoords() {
Expand Down
29 changes: 19 additions & 10 deletions packages/app-builder/src/components/Screenings/EntityProperties.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import {
BirthdDateAverage,
createPropertyTransformer,
getSanctionEntityProperties,
IconDot,
isPropertyListed,
type PropertyForSchema,
type ScreeningEntityProperty,
} from '@app-builder/constants/screening-entity';
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<T extends OpenSanctionEntity>({
Expand Down Expand Up @@ -63,23 +65,25 @@ export function EntityProperties<T extends OpenSanctionEntity>({
{entityPropertyList.map(({ property, values, restItemsCount }) => {
return (
<Fragment key={property}>
<span className="opacity-50">
<div className="opacity-50">
{t(`screenings:entity.property.${property}`, {
defaultValue: property,
})}
</span>
<span className="wrap-break-word">
{values.length > 0 ? (
<>
</div>
<div className="wrap-break-word">
{property === 'birthDate' ? (
<BirthdDateAverage values={values} />
) : values.length > 0 ? (
<PropertyContainer property={property}>
{values.map((v, i) => (
<Fragment key={i}>
<TransformProperty property={property} value={v} />
{i === values.length - 1 ? null : <span className="mx-1">·</span>}
{i === values.length - 1 || isPropertyListed(property) ? null : <IconDot spaced />}
</Fragment>
))}
{restItemsCount > 0 ? (
<>
<span className="mx-1">·</span>
{isPropertyListed(property) ? null : <IconDot spaced />}
<button
onClick={(e) => {
e.preventDefault();
Expand All @@ -91,15 +95,20 @@ export function EntityProperties<T extends OpenSanctionEntity>({
</button>
</>
) : null}
</>
</PropertyContainer>
) : (
<span className="text-grey-secondary">not available</span>
)}
</span>
</div>
</Fragment>
);
})}
{after}
</div>
);
}

function PropertyContainer({ property, children }: { property: ScreeningEntityProperty; children: ReactNode }) {
if (isPropertyListed(property)) return <ul>{children}</ul>;
return <Fragment>{children}</Fragment>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,17 +12,21 @@ 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);

const entitySchema = entity.schema.toLowerCase();

return (
<Collapsible.Container defaultOpen={defaultOpen} onOpenChange={setIsOpen}>
<Collapsible.Title iconPosition="left">
<Collapsible.Title
iconPosition="left"
className={cn(background === 'grey' && 'bg-grey-background-light', background === 'card' && 'bg-surface-card')}
>
<div className="text-s flex flex-wrap items-center gap-x-2 gap-y-1 flex-1">
<span className="font-semibold">{entity.caption}</span>

Expand All @@ -40,10 +44,12 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM
</div>
</Collapsible.Title>

<Collapsible.Content>
<Collapsible.Content
className={cn(background === 'grey' && 'bg-grey-background-light', background === 'card' && 'bg-surface-card')}
>
<div className="text-s flex flex-col gap-6 p-4">
{entitySchema === 'person' && entity.datasets?.length ? (
<div className="grid grid-cols-[168px_1fr] gap-2">
<div className="grid grid-cols-[146px_1fr] gap-2">
<div className="font-bold">{t('screenings:match.datasets.title')}</div>
<div>
<ul>
Expand All @@ -56,7 +62,7 @@ export function FreeformMatchCard({ entity, defaultOpen, searchTerm }: FreeformM
</div>
</div>
) : null}
<DataContent entityId={entity.id} searchTerm={searchTerm} isOpen={isOpen} />
<FreeFormMatchCardDataContent entityId={entity.id} searchTerm={searchTerm} isOpen={isOpen} />
</div>
</Collapsible.Content>
</Collapsible.Container>
Expand All @@ -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 <Spinner className="size-6" />;
Expand All @@ -74,6 +90,7 @@ function DataContent({ entityId, searchTerm, isOpen }: { entityId: string; searc
if (!entity) return <div>{t('screenings:match.enriched_data_error')}</div>;
return (
<div className="text-s flex flex-col gap-6 p-4">
{withTopics && <TopicsDisplay entity={entity} containerClassName="flex w-full flex-wrap gap-1 font-normal" />}
<MatchDetails entity={entity} highlightText={searchTerm} />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FreeformSearchForm } from './FreeformSearchForm';
import { FreeformSearchResults } from './FreeformSearchResults';

export interface FreeformSearchState {
searchId: string;
results: ScreeningMatchPayload[];
inputs: {
entityType: SearchableSchema;
Expand All @@ -29,13 +30,14 @@ export const FreeformSearchPage: FunctionComponent<FreeformSearchPageProps> = ({
const [searchTerm, setSearchTerm] = useState<string | undefined>(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<string, string>,
Expand Down
Loading
Loading