-
+
+
{t('continuousScreening:creation.objectMapping.configurator.fieldMapping.title')}
-
+
+ {mode !== 'view' && (
+
+ )}
{table.fields
@@ -310,7 +341,7 @@ const FtmFieldSelector = ({
}: {
ftmEntity: string;
ftmProperty: string | null;
- availableProperties: string[];
+ availableProperties: readonly string[];
readOnly: boolean;
onChange: (ftmProperty: string | null) => void;
}) => {
diff --git a/packages/app-builder/src/components/ContinuousScreening/shared/Capsule.tsx b/packages/app-builder/src/components/ContinuousScreening/shared/Capsule.tsx
deleted file mode 100644
index 70e861bf4..000000000
--- a/packages/app-builder/src/components/ContinuousScreening/shared/Capsule.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { ReactNode } from 'react';
-import { cn } from 'ui-design-system';
-
-type CapsuleProps = { children: ReactNode; className?: string };
-
-export const Capsule = ({ children, className }: CapsuleProps) => {
- return (
-
{children}
- );
-};
diff --git a/packages/app-builder/src/components/ContinuousScreening/shared/RecapRow.tsx b/packages/app-builder/src/components/ContinuousScreening/shared/RecapRow.tsx
index 50d2a169a..5e2ff9197 100644
--- a/packages/app-builder/src/components/ContinuousScreening/shared/RecapRow.tsx
+++ b/packages/app-builder/src/components/ContinuousScreening/shared/RecapRow.tsx
@@ -1,4 +1,4 @@
-import { Capsule } from './Capsule';
+import { Tag } from 'ui-design-system';
export const RecapRow = ({ children }: { children: React.ReactNode }) => {
return
{children}
;
@@ -6,8 +6,11 @@ export const RecapRow = ({ children }: { children: React.ReactNode }) => {
export const RecapCapsule = ({ children }: { children: React.ReactNode }) => {
return (
-
+
{children}
-
+
);
};
diff --git a/packages/app-builder/src/components/ContinuousScreening/validation/DatasetSelectionSection.tsx b/packages/app-builder/src/components/ContinuousScreening/validation/DatasetSelectionSection.tsx
index 3b8f7f8d0..d980784f6 100644
--- a/packages/app-builder/src/components/ContinuousScreening/validation/DatasetSelectionSection.tsx
+++ b/packages/app-builder/src/components/ContinuousScreening/validation/DatasetSelectionSection.tsx
@@ -35,6 +35,7 @@ export const DatasetSelectionSection = ({ updatedConfig, baseConfig }: EditionVa
))
.with({ isSuccess: true }, ({ data: { datasets } }) => {
+ if (!datasets?.sections) return null;
const datasetsArray = R.pipe(
datasets.sections,
R.flatMap(R.prop('datasets')),
diff --git a/packages/app-builder/src/components/CopyToClipboardButton.tsx b/packages/app-builder/src/components/CopyToClipboardButton.tsx
index 0ec8662ca..5e519e737 100644
--- a/packages/app-builder/src/components/CopyToClipboardButton.tsx
+++ b/packages/app-builder/src/components/CopyToClipboardButton.tsx
@@ -11,6 +11,7 @@ const variances = cva(
size: {
sm: 'p-0.5 gap-2',
lg: 'min-h-8 gap-3 px-2',
+ chip: 'py-v2-xs px-v2-sm gap-v2-xs font-semibold text-small bg-surface-card',
},
dimmed: {
true: 'text-grey-secondary',
@@ -48,7 +49,7 @@ export const CopyToClipboardButton = forwardRef
diff --git a/packages/app-builder/src/components/ListAndTopicConfiguration/DatasetSelectionContent.tsx b/packages/app-builder/src/components/ListAndTopicConfiguration/DatasetSelectionContent.tsx
new file mode 100644
index 000000000..840496ae9
--- /dev/null
+++ b/packages/app-builder/src/components/ListAndTopicConfiguration/DatasetSelectionContent.tsx
@@ -0,0 +1,653 @@
+import { DatasetTag } from '@app-builder/components/Screenings/DatasetTag';
+import { Spinner } from '@app-builder/components/Spinner';
+import { type AvailableFeatures, type ScreeningCategory } from '@app-builder/models/screening';
+import { useListConfigQuery } from '@app-builder/queries/screening/lists-config';
+import { UseQueryResult } from '@tanstack/react-query';
+import { useLayoutEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { capitalize } from 'remeda';
+import { match } from 'ts-pattern';
+import { Button, Checkbox, type CheckedState, Collapsible, cn, MenuCommand, ScrollAreaV2, Tag } from 'ui-design-system';
+import { Icon } from 'ui-icons';
+import { ListAndTopicDatasetConfiguration } from './context/ListAndTopicDatasetConfiguration';
+import { getSectionLeafNames } from './dataset-utils';
+
+type ListConfig = NonNullable
>['data']>;
+type SectionData = NonNullable;
+
+function groupCheckState(names: string[], datasetsMap: Record): CheckedState {
+ if (names.length === 0) return false;
+ const selected = names.filter((n) => datasetsMap[n]).length;
+ if (selected === 0) return false;
+ if (selected === names.length) return true;
+ return 'indeterminate';
+}
+
+export function DatasetSelectionContent({ useCase }: { useCase: AvailableFeatures }) {
+ const listConfigQuery = useListConfigQuery(useCase);
+ const { t } = useTranslation(['common', 'continuousScreening']);
+
+ return (
+ <>
+
+ {t('continuousScreening:creation.datasetSelection.list.title')}
+
+
+
+ {match(listConfigQuery)
+ .with({ isPending: true }, () => (
+
+
+
+ ))
+ .with({ isError: true }, () => (
+
+
{t('common:generic_fetch_data_error')}
+
+
+ ))
+ .with({ isSuccess: true }, ({ data }) => (
+
+ {data &&
+ Object.entries(data)
+ .filter(([, section]) => section.datasets?.length || section.topics)
+ .map(([key, section]) =>
+ section ? : null,
+ )}
+
+ ))
+ .exhaustive()}
+
+ >
+ );
+}
+
+const SelectedListsCount = ({ listConfigQuery }: { listConfigQuery: UseQueryResult }) => {
+ const { t } = useTranslation(['continuousScreening']);
+ const datasets = ListAndTopicDatasetConfiguration.select((state) => state.datasets);
+ const sectionCount = Object.keys(listConfigQuery.data ?? {}).filter((k) => !!datasets[k]).length;
+ return {t('continuousScreening:creation.datasetSelection.list.count', { count: sectionCount })};
+};
+
+const Section = ({ sectionKey, section }: { sectionKey: ScreeningCategory; section: SectionData }) => {
+ const listConfig = ListAndTopicDatasetConfiguration.useSharp();
+ const mode = ListAndTopicDatasetConfiguration.select((state) => state.mode);
+
+ const leafNames = getSectionLeafNames(section);
+ const isEnabled = ListAndTopicDatasetConfiguration.select((state) => !!state.datasets[sectionKey]);
+ const selectedCount = ListAndTopicDatasetConfiguration.select(
+ (state) => leafNames.filter((n) => state.datasets[n]).length,
+ );
+
+ return (
+
+
+
+
+ e.stopPropagation()} className="inline-flex">
+ {
+ listConfig.update((state) => {
+ const nextValue = !state.datasets[sectionKey];
+ state.datasets[sectionKey] = nextValue;
+ });
+ }}
+ />
+
+
+
+
+
+ {selectedCount} / {leafNames.length}
+
+
+
+
+
+
+
+ );
+};
+
+const SectionContent = ({ sectionKey, section }: { sectionKey: ScreeningCategory; section: SectionData }) => {
+ const { datasets, topics, conditionalTopics } = section;
+ const listConfig = ListAndTopicDatasetConfiguration.useSharp();
+
+ if (!datasets?.length && !topics && !conditionalTopics) return null;
+
+ function makeResetHandler(dependsOnKey: string): (() => void) | undefined {
+ if (!conditionalTopics) return undefined;
+ const dependents = Object.values(conditionalTopics).filter((ct) => ct.dependsOn === dependsOnKey);
+ const dependsOnTopics = topics?.[dependsOnKey] ?? [];
+ if (dependents.length === 0) return undefined;
+ return () => {
+ const selectedPrefixes = dependsOnTopics.filter((t) => listConfig.value.datasets[t.name]).map((t) => t.name);
+ if (selectedPrefixes.length === 0) return;
+ listConfig.update((state) => {
+ for (const ct of dependents) {
+ for (const item of ct.items) {
+ const prefix = item.key.split('.')[0];
+ if (!selectedPrefixes.some((sel) => sel === prefix)) {
+ state.datasets[item.name] = false;
+ }
+ }
+ }
+ });
+ };
+ }
+
+ return (
+
+ {datasets?.map((group) => (
+
+ ))}
+ {topics &&
+ Object.entries(topics).map(([key, items]) => (
+
+ ))}
+ {conditionalTopics &&
+ Object.entries(conditionalTopics).map(([name, { items, dependsOn }]) => (
+ t.name) ?? []}
+ />
+ ))}
+
+ );
+};
+
+const ItemGroup = ({
+ title,
+ items,
+ sectionKey,
+}: {
+ title: string;
+ items: { name: string; title?: string }[];
+ sectionKey: ScreeningCategory;
+}) => {
+ const { t } = useTranslation(['continuousScreening']);
+ const listConfig = ListAndTopicDatasetConfiguration.useSharp();
+ const mode = ListAndTopicDatasetConfiguration.select((state) => state.mode);
+ const names = items.map((i) => i.name);
+ const checkState = ListAndTopicDatasetConfiguration.select(
+ (state): CheckedState => groupCheckState(names, state.datasets),
+ );
+ const selectedCount = ListAndTopicDatasetConfiguration.select(
+ (state) => names.filter((n) => state.datasets[n]).length,
+ );
+
+ const handleSelectAll = () => {
+ const datasetsMap = listConfig.value.datasets;
+ const selected = names.filter((n) => datasetsMap[n]).length;
+ const nextValue = selected < names.length;
+ listConfig.update((state) => {
+ for (const name of names) {
+ state.datasets[name] = nextValue;
+ }
+ if (nextValue) {
+ state.datasets[sectionKey] = true;
+ }
+ });
+ };
+
+ return (
+
+
+
+
+
+ {capitalize(title)}
+
+ {selectedCount} / {names.length}
+
+
+
+
+ {t(
+ `continuousScreening:creation.datasetSelection.list.section.${checkState === true ? 'unselect_all' : 'select_all'}`,
+ )}
+
+ e.stopPropagation()} className="inline-flex mr-6">
+
+
+
+
+
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+const ItemRow = ({ name, label, sectionKey }: { name: string; label: string; sectionKey: ScreeningCategory }) => {
+ const listConfig = ListAndTopicDatasetConfiguration.useSharp();
+ const mode = ListAndTopicDatasetConfiguration.select((state) => state.mode);
+ const isSelected = ListAndTopicDatasetConfiguration.select((state) => !!state.datasets[name]);
+
+ const onClickItem = () => {
+ if (mode === 'view') return;
+ listConfig.update((state) => {
+ const nextValue = !state.datasets[name];
+ state.datasets[name] = nextValue;
+ if (nextValue) {
+ state.datasets[sectionKey] = true;
+ }
+ });
+ };
+
+ return (
+
+
+ {label}
+
+ );
+};
+
+const OVERFLOW_TAG_WIDTH_PX = 36;
+
+function formatItemName(name: string): string {
+ const last = name.split('.').at(-1) ?? name;
+ return capitalize(last);
+}
+
+const RemovableTag = ({ label, onRemove }: { label: string; onRemove: () => void }) => (
+
+
+ {/* Keep layout width stable (no flex-wrap flicker) while animating the visual centering. */}
+
+ {label}
+
+
+
+
+
+
+);
+
+const ViewTag = ({ label }: { label: string }) => (
+
+ {label}
+
+);
+
+type TopicItem = NonNullable[keyof NonNullable][number];
+type ConditionalTopicItem = NonNullable[keyof NonNullable<
+ SectionData['conditionalTopics']
+>]['items'][number];
+
+const ConditionalFilterGroupRow = ({
+ sectionKey,
+ groupKey,
+ allItems,
+ dependsOnItems,
+}: {
+ sectionKey: ScreeningCategory;
+ groupKey: string;
+ allItems: ConditionalTopicItem[];
+ dependsOnItems: string[];
+}) => {
+ const selectedPrefixes = ListAndTopicDatasetConfiguration.select((state) =>
+ dependsOnItems.filter((t) => state.datasets[t]).map((t) => t),
+ );
+
+ const filteredItems =
+ selectedPrefixes.length === 0
+ ? allItems
+ : allItems.filter((item) => {
+ const prefix = item.key.split('.')[0];
+ return selectedPrefixes.some((sel) => sel === prefix);
+ });
+
+ if (filteredItems.length === 0) return null;
+
+ return ;
+};
+
+const FilterGroupRow = ({
+ sectionKey,
+ groupKey,
+ items,
+ onAfterChange,
+}: {
+ sectionKey: ScreeningCategory;
+ groupKey: string;
+ items: TopicItem[];
+ onAfterChange?: () => void;
+}) => {
+ const mode = ListAndTopicDatasetConfiguration.select((state) => state.mode);
+ const label = capitalize(groupKey);
+
+ return (
+
+
{label}:
+
+ {items.length === 1 && items[0] ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+const SingleItemToggle = ({
+ item,
+ sectionKey,
+ mode,
+ onAfterChange,
+}: {
+ item: TopicItem;
+ sectionKey: ScreeningCategory;
+ mode: string;
+ onAfterChange?: () => void;
+}) => {
+ const listConfig = ListAndTopicDatasetConfiguration.useSharp();
+ const isSelected = ListAndTopicDatasetConfiguration.select((state) => !!state.datasets[item.name]);
+ const { t } = useTranslation(['continuousScreening']);
+
+ if (isSelected) {
+ if (mode !== 'view') {
+ return (
+ {
+ listConfig.update((state) => {
+ state.datasets[item.name] = false;
+ });
+ onAfterChange?.();
+ }}
+ />
+ );
+ }
+ return (
+
+ {formatItemName(item.name)}
+
+ );
+ }
+
+ if (mode === 'view') return null;
+
+ return (
+
+ );
+};
+
+const MENU_BUTTON_SIZE_PX = 24; // size-6 = 1.5rem = 24px
+
+const FilterGroupTags = ({
+ items,
+ sectionKey,
+ onAfterChange,
+}: {
+ items: TopicItem[];
+ sectionKey: ScreeningCategory;
+ onAfterChange?: () => void;
+}) => {
+ const { t } = useTranslation(['continuousScreening']);
+ const listConfig = ListAndTopicDatasetConfiguration.useSharp();
+ const mode = ListAndTopicDatasetConfiguration.select((state) => state.mode);
+ const selectedItems = ListAndTopicDatasetConfiguration.select((state) => items.filter((i) => state.datasets[i.name]));
+ const [isExpanded, setIsExpanded] = useState(false);
+ const containerRef = useRef(null);
+ const ghostRef = useRef(null);
+ const [maxVisible, setMaxVisible] = useState(selectedItems.length);
+
+ const selectedKey = selectedItems.map((i) => i.name).join(',');
+
+ useLayoutEffect(() => {
+ if (isExpanded) return;
+ const container = containerRef.current;
+ const ghost = ghostRef.current;
+ if (!container || !ghost) return;
+
+ const recalculate = () => {
+ const gap = parseFloat(getComputedStyle(ghost).gap) || 4;
+ // subtract menu button + one gap when in edit mode
+ const menuReserved = mode !== 'view' ? MENU_BUTTON_SIZE_PX + gap : 0;
+ const availableWidth = container.offsetWidth - menuReserved;
+ 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 gapBefore = i > 0 ? gap : 0;
+ const isLast = i === tagEls.length - 1;
+ // reserve space for overflow tag on all but the last slot
+ const needed = used + gapBefore + tw + (isLast ? 0 : gap + OVERFLOW_TAG_WIDTH_PX);
+ if (needed <= availableWidth) {
+ used += gapBefore + tw;
+ count++;
+ } else {
+ break;
+ }
+ }
+ setMaxVisible(Math.max(count, 1));
+ };
+
+ const observer = new ResizeObserver(recalculate);
+ observer.observe(container);
+ recalculate();
+ return () => observer.disconnect();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isExpanded, selectedKey, mode]);
+
+ const isAllSelected = selectedItems.length === items.length && items.length > 1;
+ const overflow = isExpanded || isAllSelected ? 0 : Math.max(0, selectedItems.length - maxVisible);
+ const visible = overflow > 0 ? selectedItems.slice(0, maxVisible) : selectedItems;
+
+ return (
+
+ {/* Ghost: invisible clone of all selected tags used to measure their rendered widths */}
+
+ {selectedItems.map((item) => {
+ const label = item.title ?? formatItemName(item.name);
+ return mode !== 'view' ? (
+ // Must match `RemovableTag` layout width, otherwise maxVisible/overflow calc is wrong.
+ undefined} />
+ ) : (
+
+ {label}
+
+ );
+ })}
+
+
+ {isAllSelected ? (
+
+ {t('continuousScreening:creation.datasetSelection.filter.all')}
+
+ ) : (
+ <>
+ {visible.map((item) =>
+ mode !== 'view' ? (
+ {
+ listConfig.update((state) => {
+ state.datasets[item.name] = false;
+ });
+ onAfterChange?.();
+ }}
+ />
+ ) : (
+
+ ),
+ )}
+ {overflow > 0 && (
+ setIsExpanded(true)}
+ >
+ +{overflow}
+
+ )}
+ {isExpanded && (
+ setIsExpanded(false)}
+ >
+
+
+ )}
+ >
+ )}
+ {mode !== 'view' && }
+
+
+ );
+};
+
+const FilterGroupMenu = ({
+ items,
+ sectionKey,
+ onAfterChange,
+}: {
+ items: TopicItem[];
+ sectionKey: ScreeningCategory;
+ onAfterChange?: () => void;
+}) => {
+ const { t } = useTranslation(['continuousScreening']);
+ const listConfig = ListAndTopicDatasetConfiguration.useSharp();
+ const datasets = ListAndTopicDatasetConfiguration.select((state) => state.datasets);
+ const allSelected = items.length > 0 && items.every((i) => !!datasets[i.name]);
+
+ return (
+
+
+
+
+
+
+ {
+ const nextValue = !allSelected;
+ listConfig.update((state) => {
+ for (const item of items) {
+ state.datasets[item.name] = nextValue;
+ }
+ if (nextValue) {
+ state.datasets[sectionKey] = true;
+ }
+ });
+ onAfterChange?.();
+ }}
+ className="border-b border-purple-primary"
+ >
+
+ {t(`continuousScreening:creation.datasetSelection.filter.${allSelected ? 'unselect_all' : 'select_all'}`)}
+
+ {allSelected && }
+
+ {items.map((item) => {
+ const isSelected = !!datasets[item.name];
+ return (
+ {
+ listConfig.update((state) => {
+ const nextValue = !state.datasets[item.name];
+ state.datasets[item.name] = nextValue;
+ if (nextValue) {
+ state.datasets[sectionKey] = true;
+ }
+ });
+ onAfterChange?.();
+ }}
+ >
+ {item.title ?? formatItemName(item.name)}
+ {isSelected && }
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/packages/app-builder/src/components/ListAndTopicConfiguration/context/ListAndTopicDatasetConfiguration.ts b/packages/app-builder/src/components/ListAndTopicConfiguration/context/ListAndTopicDatasetConfiguration.ts
new file mode 100644
index 000000000..e754a70e6
--- /dev/null
+++ b/packages/app-builder/src/components/ListAndTopicConfiguration/context/ListAndTopicDatasetConfiguration.ts
@@ -0,0 +1,20 @@
+import { createSharpFactory } from 'sharpstate';
+
+export type ListAndTopicDatasetConfigurationMode = 'view' | 'edit' | 'create';
+
+type ListAndTopicDatasetConfigurationParams = {
+ datasets: Record;
+ mode: ListAndTopicDatasetConfigurationMode;
+};
+
+export const ListAndTopicDatasetConfiguration = createSharpFactory({
+ name: 'ListAndTopicDatasetConfiguration',
+ initializer: (params: ListAndTopicDatasetConfigurationParams) => ({
+ datasets: params.datasets,
+ mode: params.mode,
+ }),
+}).withActions({
+ setMode(api, mode: ListAndTopicDatasetConfigurationMode) {
+ api.value.mode = mode;
+ },
+});
diff --git a/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-selection-provider-utils.ts b/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-selection-provider-utils.ts
new file mode 100644
index 000000000..79012dacc
--- /dev/null
+++ b/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-selection-provider-utils.ts
@@ -0,0 +1,18 @@
+import {
+ ListAndTopicDatasetConfiguration,
+ ListAndTopicDatasetConfigurationMode,
+} from './context/ListAndTopicDatasetConfiguration';
+
+export function makeDatasetsMap(selected: string[]): Record {
+ return Object.fromEntries(selected.map((name) => [name, true]));
+}
+
+export function useListAndTopicDatasetConfigurationSharp(params: {
+ datasets: Record;
+ mode: ListAndTopicDatasetConfigurationMode;
+}) {
+ return ListAndTopicDatasetConfiguration.createSharp({
+ datasets: params.datasets,
+ mode: params.mode,
+ });
+}
diff --git a/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-utils.ts b/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-utils.ts
new file mode 100644
index 000000000..292362502
--- /dev/null
+++ b/packages/app-builder/src/components/ListAndTopicConfiguration/dataset-utils.ts
@@ -0,0 +1,14 @@
+import type { ListConfigFilters } from '@app-builder/queries/screening/lists-config';
+
+type SectionData = NonNullable;
+
+export function getSectionLeafNames(section: SectionData): string[] {
+ const datasetNames = (section.datasets ?? []).flatMap((g) => g.datasets.map((d) => d.name));
+ const topicNames = Object.values(section.topics ?? {}).flatMap((items) => items.map((i) => i.name));
+ const conditionalTopicNames = Object.values(section.conditionalTopics ?? {}).flatMap((ct) =>
+ ct.items.map((i) => i.name),
+ );
+
+ // Note: `sectionKey` is intentionally not included: it's the section toggle, not a leaf item.
+ return [...new Set([...datasetNames, ...topicNames, ...conditionalTopicNames])];
+}
diff --git a/packages/app-builder/src/components/ListAndTopicConfiguration/index.ts b/packages/app-builder/src/components/ListAndTopicConfiguration/index.ts
new file mode 100644
index 000000000..32e84ec3e
--- /dev/null
+++ b/packages/app-builder/src/components/ListAndTopicConfiguration/index.ts
@@ -0,0 +1,7 @@
+export {
+ ListAndTopicDatasetConfiguration,
+ type ListAndTopicDatasetConfigurationMode,
+} from './context/ListAndTopicDatasetConfiguration';
+export { DatasetSelectionContent } from './DatasetSelectionContent';
+export { makeDatasetsMap, useListAndTopicDatasetConfigurationSharp } from './dataset-selection-provider-utils';
+export { getSectionLeafNames } from './dataset-utils';
diff --git a/packages/app-builder/src/components/Scenario/Screening/FieldDataset.tsx b/packages/app-builder/src/components/Scenario/Screening/FieldDataset.tsx
index 2e50c13d6..13bb5e8a8 100644
--- a/packages/app-builder/src/components/Scenario/Screening/FieldDataset.tsx
+++ b/packages/app-builder/src/components/Scenario/Screening/FieldDataset.tsx
@@ -1,168 +1,63 @@
import { Callout } from '@app-builder/components/Callout';
-import { DatasetTag } from '@app-builder/components/Screenings/DatasetTag';
-import { type ScreeningCategory } from '@app-builder/models/screening';
-import { useEditorMode } from '@app-builder/services/editor/editor-mode';
-import clsx from 'clsx';
-import Fuse from 'fuse.js';
-import { type OpenSanctionsCatalogSection } from 'marble-api';
-import { diff, toggle } from 'radash';
-import { type Dispatch, memo, type SetStateAction, useEffect, useMemo, useState } from 'react';
+import {
+ DatasetSelectionContent,
+ ListAndTopicDatasetConfiguration,
+ makeDatasetsMap,
+} from '@app-builder/components/ListAndTopicConfiguration';
+import { useSignalEffect } from '@preact/signals-react';
+import { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { concat, intersection, map, pipe, unique } from 'remeda';
-import { Checkbox, CollapsibleV2, cn } from 'ui-design-system';
-import { Icon } from 'ui-icons';
-import { type DatasetFiltersForm, FieldDatasetFilters } from './FieldDatasetFilters';
+function getSelectedDatasets(datasets: Record): string[] {
+ return Object.keys(datasets)
+ .filter((key) => datasets[key])
+ .sort();
+}
-const FieldCategory = memo(function FieldCategory({
- section,
- selectedIds,
- filters,
- updateSelectedIds,
-}: {
- section: OpenSanctionsCatalogSection;
- selectedIds: string[];
- filters: DatasetFiltersForm;
- updateSelectedIds: Dispatch>;
-}) {
- const { t } = useTranslation(['common', 'scenarios']);
- const [open, setOpen] = useState(false);
- const editor = useEditorMode();
+function getDatasetsKey(datasets: string[]): string {
+ return [...datasets].sort().join(',');
+}
- const sectionDatasetIds = useMemo(() => section.datasets.map((dataset) => dataset.name), [section.datasets]);
+export const FieldDataset = ({ value, onChange }: { value?: string[]; onChange?: (value: string[]) => void }) => {
+ const { t } = useTranslation();
+ const valueKey = useMemo(() => getDatasetsKey(value ?? []), [value]);
- const selectedDatasetIds = useMemo(
- () => intersection(sectionDatasetIds, selectedIds),
- [sectionDatasetIds, selectedIds],
- );
+ const listSharp = ListAndTopicDatasetConfiguration.createSharp({
+ datasets: makeDatasetsMap(value ?? []),
+ mode: 'edit',
+ });
- const datasetIdsToShow = useMemo(
- () =>
- pipe(
- section.datasets,
- // We filter the datasets by user search if any
- (datasets) =>
- filters.search !== ''
- ? new Fuse(datasets, {
- keys: ['title'],
- minMatchCharLength: 3,
- threshold: 0.2,
- })
- .search(filters.search)
- .map((i) => i.item)
- : datasets,
- // We filter the resulted datasets by selected tags if any
- (datasets) =>
- filters.tags.length > 0 ? datasets.filter((d) => d.tag && filters.tags.includes(d.tag)) : datasets,
- // We get only the ids
- map((d) => d.name),
- // We don't forget to add the selected dataset ids
- concat(selectedDatasetIds),
- // Convenience
- unique(),
- ),
- [filters, section, selectedDatasetIds],
- );
+ const onChangeRef = useRef(onChange);
+ onChangeRef.current = onChange;
+ const lastValueKeyRef = useRef(valueKey);
+ lastValueKeyRef.current = valueKey;
- const isAllSelected = useMemo(
- () => diff(datasetIdsToShow, selectedDatasetIds).length === 0,
- [datasetIdsToShow, selectedDatasetIds],
- );
+ useEffect(() => {
+ const selectedKey = getDatasetsKey(getSelectedDatasets(listSharp.value.datasets));
+ if (selectedKey === valueKey) return;
- return datasetIdsToShow.length > 0 ? (
-
-
-
-
setOpen(!open)} className="flex flex-row items-center gap-2">
-
- {section.title}
-
-
-
- {isAllSelected
- ? t('common:all_selected')
- : selectedDatasetIds.length > 0
- ? t('scenarios:sanction.lists.nb_selected', {
- count: selectedDatasetIds.length,
- })
- : t('common:select_all')}
-
- 0 ? 'indeterminate' : false}
- onCheckedChange={(state) => {
- updateSelectedIds((prev) => {
- let result: string[] = [...prev];
- const idsToToggle = state
- ? diff(datasetIdsToShow, result)
- : datasetIdsToShow.filter((id) => result.includes(id));
- for (const id of idsToToggle) {
- result = toggle(result, id);
- }
- return result;
- });
- }}
- />
-
-
-
- 0,
- })}
- >
- {section.datasets
- .filter((d) => datasetIdsToShow.includes(d.name))
- .map((dataset) => (
-
- ))}
-
-
-
-
- ) : null;
-});
+ const nextDatasets = makeDatasetsMap(value ?? []);
+ listSharp.update((state) => {
+ for (const key of Object.keys(state.datasets)) {
+ delete state.datasets[key];
+ }
+ for (const [key, isSelected] of Object.entries(nextDatasets)) {
+ state.datasets[key] = isSelected;
+ }
+ });
+ }, [listSharp, value, valueKey]);
-export const FieldDataset = ({
- onChange,
- onBlur,
- sections,
- defaultValue,
-}: {
- defaultValue?: string[];
- sections: OpenSanctionsCatalogSection[];
- onChange?: (value: string[]) => void;
- onBlur?: () => void;
-}) => {
- const [selectedIds, updateSelectedIds] = useState(defaultValue ?? []);
- const [filters, setFilters] = useState({ tags: [], search: '' });
- const { t } = useTranslation();
+ useSignalEffect(() => {
+ const selectedDatasets = getSelectedDatasets(listSharp.value.datasets);
+ const selectedKey = getDatasetsKey(selectedDatasets);
- useEffect(() => {
- onChange?.(selectedIds);
- }, [selectedIds]);
+ if (selectedKey === lastValueKeyRef.current) {
+ return;
+ }
+
+ lastValueKeyRef.current = selectedKey;
+ onChangeRef.current?.(selectedDatasets);
+ });
return (
@@ -171,18 +66,9 @@ export const FieldDataset = ({
{t('scenarios:sanction.lists.callout')}
-
-
- {sections.map((section) => (
-
- ))}
-
+
+
+
);
diff --git a/packages/app-builder/src/components/Screenings/FreeformSearch/DatasetsPopover.tsx b/packages/app-builder/src/components/Screenings/FreeformSearch/DatasetsPopover.tsx
index 10dc75037..21e6404c7 100644
--- a/packages/app-builder/src/components/Screenings/FreeformSearch/DatasetsPopover.tsx
+++ b/packages/app-builder/src/components/Screenings/FreeformSearch/DatasetsPopover.tsx
@@ -1,16 +1,17 @@
-import { type ScreeningCategory } from '@app-builder/models/screening';
-import { useScreeningDatasetsQuery } from '@app-builder/queries/screening/datasets';
-import * as Collapsible from '@radix-ui/react-collapsible';
+import {
+ DatasetSelectionContent,
+ getSectionLeafNames,
+ ListAndTopicDatasetConfiguration,
+ makeDatasetsMap,
+ useListAndTopicDatasetConfigurationSharp,
+} from '@app-builder/components/ListAndTopicConfiguration';
+import { useListConfigQuery } from '@app-builder/queries/screening/lists-config';
import * as Popover from '@radix-ui/react-popover';
import clsx from 'clsx';
-import { type OpenSanctionsCatalogSection } from 'marble-api';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { match } from 'ts-pattern';
-import { Button, Checkbox, Input } from 'ui-design-system';
+import { Button } from 'ui-design-system';
import { Icon } from 'ui-icons';
-
-import { DatasetTag } from '../DatasetTag';
import { screeningsI18n } from '../screenings-i18n';
export interface DatasetsPopoverProps {
@@ -20,63 +21,41 @@ export interface DatasetsPopoverProps {
export const DatasetsPopover = ({ selectedDatasets, onApply }: DatasetsPopoverProps) => {
const { t } = useTranslation(screeningsI18n);
- const datasetsQuery = useScreeningDatasetsQuery();
+ const listConfigQuery = useListConfigQuery('manual_search');
const [open, setOpen] = useState(false);
- const [tempSelected, setTempSelected] = useState