Skip to content

Commit a9fa857

Browse files
authored
ITEP-68003 - Import keypoint dataset to existing project (#496)
1 parent 96fbcfa commit a9fa857

9 files changed

+392
-52
lines changed

web_ui/src/pages/project-details/components/project-dataset/dataset-import-to-existing-project/dataset-import-to-existing-project-dialog-buttons.component.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Copyright (C) 2022-2025 Intel Corporation
22
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
33

4-
import { useMemo } from 'react';
4+
import { ReactNode, useMemo } from 'react';
55

6-
import { Button, ButtonGroup } from '@geti/ui';
6+
import { Button, ButtonGroup, View } from '@geti/ui';
77
import { OverlayTriggerState } from '@react-stately/overlays';
88
import { capitalize } from 'lodash-es';
99

@@ -18,15 +18,19 @@ import { useWorkspaceIdentifier } from '../../../../../providers/workspaces-prov
1818
import { isNonEmptyString } from '../../../../../shared/utils';
1919

2020
interface DatasetImportToExistingProjectDialogButtonsProps {
21-
deletionDialogTriggerState: OverlayTriggerState;
21+
children?: ReactNode;
2222
datasetImportItem: DatasetImportItem | undefined;
23+
isImportDisabled: boolean;
24+
deletionDialogTriggerState: OverlayTriggerState;
2325
onDialogDismiss: () => void;
2426
onPrimaryAction: () => void;
2527
}
2628

2729
export const DatasetImportToExistingProjectDialogButtons = ({
28-
deletionDialogTriggerState,
30+
children,
2931
datasetImportItem,
32+
isImportDisabled,
33+
deletionDialogTriggerState,
3034
onDialogDismiss,
3135
onPrimaryAction,
3236
}: DatasetImportToExistingProjectDialogButtonsProps): JSX.Element => {
@@ -102,7 +106,7 @@ export const DatasetImportToExistingProjectDialogButtons = ({
102106
DATASET_IMPORT_STATUSES.READY,
103107
DATASET_IMPORT_STATUSES.LABELS_MAPPING_TO_EXISTING_PROJECT,
104108
]),
105-
disabled: !isReady(datasetImportItem?.id),
109+
disabled: !isReady(datasetImportItem?.id) || isImportDisabled,
106110
variant: 'accent',
107111
action: () => {
108112
onPrimaryAction();
@@ -124,6 +128,10 @@ export const DatasetImportToExistingProjectDialogButtons = ({
124128

125129
return (
126130
<ButtonGroup>
131+
<View height={'100%'} marginEnd={'auto'}>
132+
{children}
133+
</View>
134+
127135
{state.map((button: DatasetImportDialogButton) => (
128136
<Button
129137
data-testid={`testid-${button.name}`}

web_ui/src/pages/project-details/components/project-dataset/dataset-import-to-existing-project/dataset-import-to-existing-project-dialog-buttons.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const renderMockedComponent = async (
7979
) => {
8080
return providersRender(
8181
<DatasetImportToExistingProjectDialogButtons
82+
isImportDisabled={false}
8283
deletionDialogTriggerState={mockDeletionDialogTriggerState}
8384
datasetImportItem={datasetImportItem}
8485
onPrimaryAction={mockOnPrimaryAction}

web_ui/src/pages/project-details/components/project-dataset/dataset-import-to-existing-project/dataset-import-to-existing-project-dialog.component.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ import { Content, Dialog, DialogContainer, Divider, Heading, View } from '@geti/
77
import { OverlayTriggerState } from '@react-stately/overlays';
88

99
import { DATASET_IMPORT_STATUSES } from '../../../../../core/datasets/dataset.enum';
10-
import { isAnomalyDomain } from '../../../../../core/projects/domains';
10+
import { isAnomalyDomain, isKeypointDetection } from '../../../../../core/projects/domains';
1111
import { useDatasetImportToExistingProject } from '../../../../../providers/dataset-import-to-existing-project-provider/dataset-import-to-existing-project-provider.component';
1212
import { matchStatus } from '../../../../../providers/dataset-import-to-existing-project-provider/utils';
1313
import { DatasetImportDnd } from '../../../../../shared/components/dataset-import-dnd/dataset-import-dnd.component';
1414
import { DatasetImportProgress } from '../../../../../shared/components/dataset-import-progress/dataset-import-progress.component';
15+
import { isNonEmptyArray } from '../../../../../shared/utils';
1516
import { useProject } from '../../../providers/project-provider/project-provider.component';
1617
import { DatasetImportToExistingProjectDialogButtons } from './dataset-import-to-existing-project-dialog-buttons.component';
1718
import { DatasetImportToExistingProjectMapLabels } from './dataset-import-to-existing-project-map-labels.component';
19+
import { KeypointErrorMessage } from './keypoint-error-message.component';
20+
import { getMissingLabels, hasDuplicatedValues } from './utils';
1821

1922
interface DatasetImportToExistingProjectDialogProps {
2023
datasetImportDialogState: OverlayTriggerState;
@@ -26,11 +29,14 @@ export const DatasetImportToExistingProjectDialog = ({
2629
datasetImportDeleteDialogState,
2730
}: DatasetImportToExistingProjectDialogProps) => {
2831
const { project } = useProject();
29-
const isAnomaly = project.domains.some(isAnomalyDomain);
30-
31-
const { setActiveDatasetImportId, activeDatasetImport, prepareDataset, importDatasetJob } =
32+
const { setActiveDatasetImportId, prepareDataset, importDatasetJob, patchDatasetImport, activeDatasetImport } =
3233
useDatasetImportToExistingProject();
3334

35+
const isAnomaly = project.domains.some(isAnomalyDomain);
36+
const isKeypoint = project.domains.some(isKeypointDetection);
37+
const hasDuplicatedMapLabels = hasDuplicatedValues(activeDatasetImport?.labelsMap);
38+
const isKeypointWithDuplicatedLabels = isKeypoint && hasDuplicatedMapLabels;
39+
3440
const showProgress = useMemo<boolean>(() => {
3541
return matchStatus(activeDatasetImport, [
3642
DATASET_IMPORT_STATUSES.UPLOADING,
@@ -47,20 +53,32 @@ export const DatasetImportToExistingProjectDialog = ({
4753
]);
4854
}, [activeDatasetImport]);
4955

50-
const dialogDismiss = (): void => {
56+
const isKeypointMapLabels = isKeypoint && showMapLabels;
57+
58+
const handleDialogDismiss = (): void => {
5159
datasetImportDialogState.close();
5260
setActiveDatasetImportId(undefined);
5361
};
5462

63+
const handlePrimaryAction = () => {
64+
if (!activeDatasetImport) return;
65+
66+
if (isKeypointMapLabels && isNonEmptyArray(getMissingLabels(project.labels, activeDatasetImport.labelsMap))) {
67+
patchDatasetImport({ id: activeDatasetImport.id, labelsMap: {} });
68+
}
69+
70+
importDatasetJob(activeDatasetImport.id);
71+
};
72+
5573
return (
56-
<DialogContainer onDismiss={dialogDismiss}>
74+
<DialogContainer onDismiss={handleDialogDismiss}>
5775
{datasetImportDialogState.isOpen && (
5876
<Dialog aria-label='import-dataset-dialog' width={800}>
5977
<Heading>Import dataset</Heading>
6078
<Divider />
6179
<Content>
6280
<View backgroundColor={'gray-50'} minHeight={'size-4600'}>
63-
{!activeDatasetImport && (
81+
{activeDatasetImport === undefined && (
6482
<DatasetImportDnd
6583
setUploadItem={prepareDataset}
6684
setActiveUploadId={setActiveDatasetImportId}
@@ -89,16 +107,18 @@ export const DatasetImportToExistingProjectDialog = ({
89107
)}
90108
</View>
91109
</Content>
110+
92111
<DatasetImportToExistingProjectDialogButtons
93-
onDialogDismiss={dialogDismiss}
112+
onDialogDismiss={handleDialogDismiss}
94113
datasetImportItem={activeDatasetImport}
95114
deletionDialogTriggerState={datasetImportDeleteDialogState}
96-
onPrimaryAction={() => {
97-
if (!activeDatasetImport) return;
98-
99-
importDatasetJob(activeDatasetImport.id);
100-
}}
101-
/>
115+
isImportDisabled={isKeypointWithDuplicatedLabels}
116+
onPrimaryAction={handlePrimaryAction}
117+
>
118+
{isKeypointMapLabels && activeDatasetImport && (
119+
<KeypointErrorMessage labels={project.labels} labelsMap={activeDatasetImport.labelsMap} />
120+
)}
121+
</DatasetImportToExistingProjectDialogButtons>
102122
</Dialog>
103123
)}
104124
</DialogContainer>

web_ui/src/pages/project-details/components/project-dataset/dataset-import-to-existing-project/dataset-import-to-existing-project-dialog.test.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { fireEvent, screen } from '@testing-library/react';
55

66
import { DATASET_IMPORT_STATUSES } from '../../../../../core/datasets/dataset.enum';
77
import { DatasetImportToExistingProjectItem } from '../../../../../core/datasets/dataset.interface';
8+
import { DOMAIN } from '../../../../../core/projects/core.interface';
89
import { useDatasetImportToExistingProject } from '../../../../../providers/dataset-import-to-existing-project-provider/dataset-import-to-existing-project-provider.component';
10+
import { getMockedLabel } from '../../../../../test-utils/mocked-items-factory/mocked-labels';
911
import {
1012
getMockedProject,
1113
mockedProjectContextProps,
@@ -14,6 +16,7 @@ import { getMockedTask } from '../../../../../test-utils/mocked-items-factory/mo
1416
import { projectListRender } from '../../../../../test-utils/projects-list-providers-render';
1517
import { useProject } from '../../../providers/project-provider/project-provider.component';
1618
import { DatasetImportToExistingProjectDialog } from './dataset-import-to-existing-project-dialog.component';
19+
import { KEYPOINT_ANNOTATION_WARNING, KEYPOINT_DUPLICATED_LABELS } from './utils';
1720

1821
const mockDatasetImportItem: DatasetImportToExistingProjectItem = {
1922
id: '987-654-321',
@@ -205,4 +208,72 @@ describe(DatasetImportToExistingProjectDialog, () => {
205208

206209
expect(mockedImportDatasetJob).toHaveBeenCalledWith(mockDatasetImportItem.id);
207210
});
211+
212+
describe('keypoint detection', () => {
213+
const neckLabel = getMockedLabel({ name: 'neck', id: '683d4ccfd01df152c3f65ff6' });
214+
const headLabel = getMockedLabel({ name: 'neck', id: '683d4ccfd01df152c3f65ff7' });
215+
216+
it('displays warning for duplicated keypoint labels', async () => {
217+
jest.mocked(useDatasetImportToExistingProject).mockReturnValue({
218+
...jest.requireActual(
219+
'../../../../../providers/dataset-import-to-existing-project-provider/dataset-import-to-existing-project-provider.component'
220+
),
221+
isReady: jest.fn(),
222+
activeDatasetImport: {
223+
...mockDatasetImportItem,
224+
labelsMap: { neck: neckLabel.id, head: neckLabel.id },
225+
status: DATASET_IMPORT_STATUSES.LABELS_MAPPING_TO_EXISTING_PROJECT,
226+
},
227+
});
228+
229+
jest.mocked(useProject).mockImplementation(() =>
230+
mockedProjectContextProps({
231+
project: getMockedProject({
232+
tasks: [getMockedTask({ domain: DOMAIN.KEYPOINT_DETECTION, labels: [neckLabel] })],
233+
}),
234+
})
235+
);
236+
237+
await renderMockedComponent({ featureFlags: { FEATURE_FLAG_KEYPOINT_DETECTION_DATASET_IE: true } });
238+
239+
expect(screen.getByText(new RegExp(KEYPOINT_DUPLICATED_LABELS))).toBeVisible();
240+
expect(screen.getByRole('button', { name: /import/i })).toBeDisabled();
241+
});
242+
243+
it('removes incomplete mapped labels', async () => {
244+
const mockedImportDatasetJob = jest.fn();
245+
const mockedPatchDatasetImport = jest.fn();
246+
jest.mocked(useDatasetImportToExistingProject).mockReturnValue({
247+
...jest.requireActual(
248+
'../../../../../providers/dataset-import-to-existing-project-provider/dataset-import-to-existing-project-provider.component'
249+
),
250+
isReady: jest.fn(() => true),
251+
importDatasetJob: mockedImportDatasetJob,
252+
patchDatasetImport: mockedPatchDatasetImport,
253+
setActiveDatasetImportId: jest.fn(),
254+
activeDatasetImport: {
255+
...mockDatasetImportItem,
256+
labelsMap: { neck: '683d4ccfd01df152c3f65ff6' },
257+
status: DATASET_IMPORT_STATUSES.LABELS_MAPPING_TO_EXISTING_PROJECT,
258+
},
259+
});
260+
261+
jest.mocked(useProject).mockImplementation(() =>
262+
mockedProjectContextProps({
263+
project: getMockedProject({
264+
tasks: [getMockedTask({ domain: DOMAIN.KEYPOINT_DETECTION, labels: [neckLabel, headLabel] })],
265+
}),
266+
})
267+
);
268+
269+
await renderMockedComponent({ featureFlags: { FEATURE_FLAG_KEYPOINT_DETECTION_DATASET_IE: true } });
270+
271+
expect(screen.getByText(new RegExp(KEYPOINT_ANNOTATION_WARNING))).toBeVisible();
272+
273+
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
274+
275+
expect(mockedImportDatasetJob).toHaveBeenCalledWith(mockDatasetImportItem.id);
276+
expect(mockedPatchDatasetImport).toHaveBeenCalledWith({ id: mockDatasetImportItem.id, labelsMap: {} });
277+
});
278+
});
208279
});

web_ui/src/pages/project-details/components/project-dataset/dataset-import-to-existing-project/dataset-import-to-existing-project-map-labels.component.tsx

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -120,41 +120,39 @@ export const DatasetImportToExistingProjectMapLabels = ({
120120
patchActiveDatasetImport({ labelsMap: omitBy(labelsMap, (_, key) => key === labelName) });
121121
};
122122

123+
if (noLabelsInMatchingStep) {
124+
return <Text>{NO_LABELS_INFORMATION}</Text>;
125+
}
126+
123127
return (
124128
<div aria-label='dataset-import-to-existing-project-map-labels'>
125-
{noLabelsInMatchingStep ? (
126-
<Text>{NO_LABELS_INFORMATION}</Text>
127-
) : (
128-
<>
129-
<Flex marginBottom='size-250' gap='size-400' justifyContent='space-between' alignItems='center'>
130-
<Text id='dataset-import-to-existing-project-existing-labels' flex={1}>
131-
Existing labels
132-
</Text>
133-
<Text id='dataset-import-to-existing-project-target-labels' flex={1}>
134-
Target labels
135-
</Text>
136-
</Flex>
137-
<VisuallyHidden>
138-
{/* We're using this field to prevent a weird focus bug when the user
139-
closes a label search component this text field will be focused instead of
140-
another label search input, preventing a results panel to be opened */}
141-
<TextField aria-label='This text field is unused' />
142-
</VisuallyHidden>
143-
<Flex gap='size-10' direction='column'>
144-
{sortedLabels.map((mappingLabelName: string, index: number) => (
145-
<DatasetImportToExistingProjectMapLabel
146-
testId={`label-mapping-${index}`}
147-
key={`${mappingLabelName}-${index}`}
148-
mappingLabelName={mappingLabelName}
149-
projectLabels={projectLabels}
150-
selectedLabelId={labelsMap[mappingLabelName]}
151-
onMappingChange={onMappingChange}
152-
onMappingClear={onMappingClear}
153-
/>
154-
))}
155-
</Flex>
156-
</>
157-
)}
129+
<Flex marginBottom='size-250' gap='size-400' justifyContent='space-between' alignItems='center'>
130+
<Text id='dataset-import-to-existing-project-existing-labels' flex={1}>
131+
Existing labels
132+
</Text>
133+
<Text id='dataset-import-to-existing-project-target-labels' flex={1}>
134+
Target labels
135+
</Text>
136+
</Flex>
137+
<VisuallyHidden>
138+
{/* We're using this field to prevent a weird focus bug when the user
139+
closes a label search component this text field will be focused instead of
140+
another label search input, preventing a results panel to be opened */}
141+
<TextField aria-label='This text field is unused' />
142+
</VisuallyHidden>
143+
<Flex gap='size-10' direction='column'>
144+
{sortedLabels.map((mappingLabelName: string, index: number) => (
145+
<DatasetImportToExistingProjectMapLabel
146+
testId={`label-mapping-${index}`}
147+
key={`${mappingLabelName}-${index}`}
148+
mappingLabelName={mappingLabelName}
149+
projectLabels={projectLabels}
150+
selectedLabelId={labelsMap[mappingLabelName]}
151+
onMappingChange={onMappingChange}
152+
onMappingClear={onMappingClear}
153+
/>
154+
))}
155+
</Flex>
158156
</div>
159157
);
160158
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (C) 2022-2025 Intel Corporation
2+
// LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
import { Flex, Text } from '@geti/ui';
5+
import { Alert } from '@geti/ui/icons';
6+
import { isEmpty } from 'lodash-es';
7+
8+
import { Label } from '../../../../../core/labels/label.interface';
9+
import { isNonEmptyArray } from '../../../../../shared/utils';
10+
import {
11+
getDuplicates,
12+
getMissingLabels,
13+
KEYPOINT_ANNOTATION_WARNING,
14+
KEYPOINT_DUPLICATED_LABELS,
15+
KEYPOINT_MISSING_LABELS,
16+
} from './utils';
17+
18+
export interface KeypointErrorProps {
19+
labelsMap: Record<string, string>;
20+
labels: Label[];
21+
}
22+
23+
const concatNames = (labels: Label[]) => labels.map(({ name }) => name).join(', ');
24+
25+
export const KeypointErrorMessage = ({ labels, labelsMap }: KeypointErrorProps) => {
26+
const labelsMapValues = Object.values(labelsMap);
27+
const duplicatedValues = getDuplicates(labelsMapValues);
28+
29+
const missingLabels = getMissingLabels(labels, labelsMap);
30+
const duplicatedLabels = labels.filter((label) => duplicatedValues.includes(label.id));
31+
32+
if (isEmpty(missingLabels) && isEmpty(duplicatedLabels)) {
33+
return <></>;
34+
}
35+
36+
return (
37+
<Flex gap={'size-100'} alignContent={'center'} height={'100%'} alignItems={'center'}>
38+
<Alert style={{ fill: 'var(--brand-coral-cobalt)', flex: 'none' }} />
39+
<Flex direction={'column'} gap={'size-100'}>
40+
{isNonEmptyArray(duplicatedLabels) && (
41+
<Text>
42+
{KEYPOINT_DUPLICATED_LABELS} {concatNames(duplicatedLabels)}
43+
</Text>
44+
)}
45+
{isNonEmptyArray(missingLabels) && (
46+
<>
47+
<Text>
48+
{KEYPOINT_MISSING_LABELS} {concatNames(missingLabels)}
49+
</Text>
50+
<Text>{KEYPOINT_ANNOTATION_WARNING}</Text>
51+
</>
52+
)}
53+
</Flex>
54+
</Flex>
55+
);
56+
};

0 commit comments

Comments
 (0)