diff --git a/application/ui/src/components/virtualizer-grid-layout/grid-media-item/grid-media-item.module.scss b/application/ui/src/components/virtualizer-grid-layout/grid-media-item/grid-media-item.module.scss
index 43024023cb..3185a35468 100644
--- a/application/ui/src/components/virtualizer-grid-layout/grid-media-item/grid-media-item.module.scss
+++ b/application/ui/src/components/virtualizer-grid-layout/grid-media-item/grid-media-item.module.scss
@@ -10,6 +10,7 @@
line-height: 0px;
padding: var(--spectrum-global-dimension-size-125);
border-radius: var(--spectrum-global-dimension-size-50);
+ background-color: var(--spectrum-gray-100);
&:empty {
display: none;
diff --git a/application/ui/src/features/inspect/dataset/dataset-item-placeholder/dataset-item-placeholder.component.tsx b/application/ui/src/features/inspect/dataset/dataset-item-placeholder/dataset-item-placeholder.component.tsx
new file mode 100644
index 0000000000..d807905abf
--- /dev/null
+++ b/application/ui/src/features/inspect/dataset/dataset-item-placeholder/dataset-item-placeholder.component.tsx
@@ -0,0 +1,13 @@
+import { Image } from '@geti-inspect/icons';
+import { Flex } from '@geti/ui';
+import { clsx } from 'clsx';
+
+import styles from './dataset-item-placeholder.module.scss';
+
+export const DatasetItemPlaceholder = () => {
+ return (
+
+
+
+ );
+};
diff --git a/application/ui/src/features/inspect/dataset/dataset-item-placeholder/dataset-item-placeholder.module.scss b/application/ui/src/features/inspect/dataset/dataset-item-placeholder/dataset-item-placeholder.module.scss
new file mode 100644
index 0000000000..93014c1a1b
--- /dev/null
+++ b/application/ui/src/features/inspect/dataset/dataset-item-placeholder/dataset-item-placeholder.module.scss
@@ -0,0 +1,7 @@
+.datasetItemPlaceholder {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: auto;
+ border: 1px dashed var(--spectrum-global-color-gray-700);
+ background-color: var(--spectrum-global-color-gray-200);
+}
diff --git a/application/ui/src/features/inspect/dataset/dataset-item-placeholder/util.tsx b/application/ui/src/features/inspect/dataset/dataset-item-placeholder/util.tsx
new file mode 100644
index 0000000000..ee232f6545
--- /dev/null
+++ b/application/ui/src/features/inspect/dataset/dataset-item-placeholder/util.tsx
@@ -0,0 +1,19 @@
+import { MediaItem } from '../types';
+
+const PLACEHOLDER_FILENAME = 'placeholder';
+
+export const isPlaceholderItem = (name: string): boolean => {
+ return name.includes(PLACEHOLDER_FILENAME);
+};
+
+export const getPlaceholderItem = (index: number): MediaItem => {
+ return {
+ id: `${PLACEHOLDER_FILENAME}-${index}`,
+ filename: PLACEHOLDER_FILENAME,
+ project_id: '',
+ size: 0,
+ is_anomalous: false,
+ width: 0,
+ height: 0,
+ };
+};
diff --git a/application/ui/src/features/inspect/dataset/dataset-item/dataset-item-placeholder.component.tsx b/application/ui/src/features/inspect/dataset/dataset-item/dataset-item-placeholder.component.tsx
deleted file mode 100644
index 271782cf68..0000000000
--- a/application/ui/src/features/inspect/dataset/dataset-item/dataset-item-placeholder.component.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Image } from '@geti-inspect/icons';
-import { Flex } from '@geti/ui';
-import { clsx } from 'clsx';
-
-import styles from './dataset-item.module.scss';
-
-export const DatasetItemPlaceholder = () => {
- return (
-
-
-
-
-
- );
-};
diff --git a/application/ui/src/features/inspect/dataset/dataset-item/dataset-item.component.tsx b/application/ui/src/features/inspect/dataset/dataset-item/dataset-item.component.tsx
deleted file mode 100644
index 0cdc110b9b..0000000000
--- a/application/ui/src/features/inspect/dataset/dataset-item/dataset-item.component.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useState } from 'react';
-
-import { Skeleton } from '@geti/ui';
-import { clsx } from 'clsx';
-
-import { DeleteMediaItem } from '../delete-dataset-item/delete-dataset-item.component';
-import { type MediaItem } from '../types';
-
-import styles from './dataset-item.module.scss';
-
-interface DatasetItemProps {
- isSelected: boolean;
- mediaItem: MediaItem;
- onClick: () => void;
- onDeleted: () => void;
-}
-
-const RETRY_LIMIT = 3;
-
-export const DatasetItem = ({ isSelected, mediaItem, onClick, onDeleted }: DatasetItemProps) => {
- const [retry, setRetry] = useState(0);
- const [isLoading, setIsLoading] = useState(true);
-
- const mediaUrl = `/api/projects/${mediaItem.project_id}/images/${mediaItem.id}/thumbnail?retry=${retry}`;
-
- const handleError = () => {
- if (retry < RETRY_LIMIT) {
- setRetry((current) => current + 1);
- setIsLoading(true);
- } else {
- setIsLoading(false);
- }
- };
-
- const handleLoad = () => {
- setIsLoading(false);
- };
-
- return (
-
- {isLoading &&
}
-
-

-
-
-
-
- );
-};
diff --git a/application/ui/src/features/inspect/dataset/dataset-item/dataset-item.module.scss b/application/ui/src/features/inspect/dataset/dataset-item/dataset-item.module.scss
deleted file mode 100644
index efdc55aa41..0000000000
--- a/application/ui/src/features/inspect/dataset/dataset-item/dataset-item.module.scss
+++ /dev/null
@@ -1,52 +0,0 @@
-.datasetItem {
- aspect-ratio: 4/3;
- cursor: pointer;
- position: relative;
-
- img {
- display: block;
- width: 100%;
- height: 100%;
- object-fit: cover;
- object-position: center;
- }
-
- border: var(--spectrum-alias-border-size-thick) solid transparent;
- transition: border-color 0.3s ease-in-out;
-
- &:hover {
- .floatingContainer {
- opacity: 1;
- }
- }
-}
-
-.datasetItemSelected {
- border-color: var(--energy-blue);
-}
-
-.datasetItemPlaceholder {
- border: 1px dashed var(--spectrum-global-color-gray-700);
- background-color: var(--spectrum-global-color-gray-200);
-}
-
-.floatingContainer {
- opacity: 0;
- position: absolute;
- line-height: 0px;
- padding: var(--spectrum-global-dimension-size-125);
- border-radius: var(--spectrum-global-dimension-size-50);
- background-color: var(--spectrum-gray-100);
-}
-
-.rightTopElement {
- top: var(--spectrum-global-dimension-size-50);
- right: var(--spectrum-global-dimension-size-50);
-}
-
-.loader {
- top: 0px;
- left: 0px;
- position: absolute;
- background-color: var(--spectrum-global-color-gray-100);
-}
diff --git a/application/ui/src/features/inspect/dataset/dataset-list.component.tsx b/application/ui/src/features/inspect/dataset/dataset-list.component.tsx
index 549f3b6b2c..94100efbce 100644
--- a/application/ui/src/features/inspect/dataset/dataset-list.component.tsx
+++ b/application/ui/src/features/inspect/dataset/dataset-list.component.tsx
@@ -1,19 +1,30 @@
-import { DialogContainer, Flex, Grid, Heading, minmax, repeat } from '@geti/ui';
+import { DialogContainer, Flex, Heading, Selection, Size, View } from '@geti/ui';
+import { isNil } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
import { useQueryState } from 'nuqs';
+import { MediaThumbnail } from 'src/components/media-thumbnail/media-thumbnail.component';
+import { GridMediaItem } from 'src/components/virtualizer-grid-layout/grid-media-item/grid-media-item.component';
+import { VirtualizerGridLayout } from 'src/components/virtualizer-grid-layout/virtualizer-grid-layout.component';
-import { DatasetItemPlaceholder } from './dataset-item/dataset-item-placeholder.component';
-import { DatasetItem } from './dataset-item/dataset-item.component';
+import { getThumbnailUrl } from '../utils';
+import { DatasetItemPlaceholder } from './dataset-item-placeholder/dataset-item-placeholder.component';
+import { getPlaceholderItem, isPlaceholderItem } from './dataset-item-placeholder/util';
+import { DeleteMediaItem } from './delete-dataset-item/delete-dataset-item.component';
+import { useGetMediaItems } from './hooks/use-get-media-items.hook';
import { MediaPreview } from './media-preview/media-preview.component';
import { InferenceOpacityProvider } from './media-preview/providers/inference-opacity-provider.component';
import { MediaItem } from './types';
import { REQUIRED_NUMBER_OF_NORMAL_IMAGES_TO_TRIGGER_TRAINING } from './utils';
-interface DatasetItemProps {
- mediaItems: MediaItem[];
-}
+const layoutOptions = {
+ maxColumns: 4,
+ minSpace: new Size(8, 8),
+ minItemSize: new Size(120, 120),
+ preserveAspectRatio: true,
+};
-export const DatasetList = ({ mediaItems }: DatasetItemProps) => {
+export const DatasetList = () => {
+ const { mediaItems, isFetchingNextPage, fetchNextPage } = useGetMediaItems();
const [selectedMediaItemId, setSelectedMediaItem] = useQueryState('selectedMediaItem');
//TODO: revisit implementation when dataset loading is paginated
const selectedMediaItem = mediaItems.find((item) => item.id === selectedMediaItemId);
@@ -22,34 +33,55 @@ export const DatasetList = ({ mediaItems }: DatasetItemProps) => {
...mediaItems,
...Array.from({
length: Math.max(0, REQUIRED_NUMBER_OF_NORMAL_IMAGES_TO_TRIGGER_TRAINING - mediaItems.length),
- }).map(() => undefined),
+ }).map((_, index): MediaItem => getPlaceholderItem(index)),
];
+ const handleSelectionChange = (newKeys: Selection) => {
+ const updatedSelectedKeys = new Set(newKeys);
+ const firstKey = updatedSelectedKeys.values().next().value;
+ const itemId = String(firstKey);
+
+ if (!isNil(firstKey) && !isPlaceholderItem(itemId)) {
+ setSelectedMediaItem(itemId);
+ }
+ };
+
return (
Normal images
-
- {mediaItemsToRender.map((mediaItem, index) =>
- isEmpty(mediaItem) ? (
-
- ) : (
- setSelectedMediaItem(mediaItem?.id ?? null)}
- onDeleted={() => selectedMediaItem?.id === mediaItem.id && setSelectedMediaItem(null)}
- />
- )
- )}
-
+
+
+ mediaItem.filename === 'placeholder' ? (
+
+ ) : (
+ (
+ setSelectedMediaItem(mediaItem.id ?? null)}
+ />
+ )}
+ topRightElement={() => (
+ setSelectedMediaItem(null)}
+ />
+ )}
+ />
+ )
+ }
+ />
+
setSelectedMediaItem(null)}>
{!isEmpty(selectedMediaItem) && (
diff --git a/application/ui/src/features/inspect/dataset/dataset.component.tsx b/application/ui/src/features/inspect/dataset/dataset.component.tsx
index 52177b5fbc..7916c0559e 100644
--- a/application/ui/src/features/inspect/dataset/dataset.component.tsx
+++ b/application/ui/src/features/inspect/dataset/dataset.component.tsx
@@ -1,27 +1,12 @@
import { Suspense } from 'react';
-import { $api } from '@geti-inspect/api';
-import { useProjectIdentifier } from '@geti-inspect/hooks';
import { Flex, Heading, Loading, View } from '@geti/ui';
import { TrainModelButton } from '../train-model/train-model-button.component';
import { DatasetList } from './dataset-list.component';
import { UploadImages } from './upload-images.component';
-const useMediaItems = () => {
- const { projectId } = useProjectIdentifier();
-
- const { data } = $api.useSuspenseQuery('get', '/api/projects/{project_id}/images', {
- params: { path: { project_id: projectId } },
- });
-
- return {
- mediaItems: data.media,
- };
-};
-
export const Dataset = () => {
- const { mediaItems } = useMediaItems();
return (
@@ -36,7 +21,7 @@ export const Dataset = () => {
}>
-
+
diff --git a/application/ui/src/features/inspect/dataset/hooks/use-get-media-items.hook.tsx b/application/ui/src/features/inspect/dataset/hooks/use-get-media-items.hook.tsx
new file mode 100644
index 0000000000..721d4f8683
--- /dev/null
+++ b/application/ui/src/features/inspect/dataset/hooks/use-get-media-items.hook.tsx
@@ -0,0 +1,39 @@
+import { $api } from '@geti-inspect/api';
+import { useProjectIdentifier } from '@geti-inspect/hooks';
+
+const mediaItemsLimit = 20;
+
+export const useGetMediaItems = () => {
+ const { projectId } = useProjectIdentifier();
+
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = $api.useInfiniteQuery(
+ 'get',
+ '/api/projects/{project_id}/images',
+ {
+ params: {
+ query: { offset: 0, limit: mediaItemsLimit },
+ path: { project_id: projectId },
+ },
+ },
+ {
+ pageParamName: 'offset',
+ getNextPageParam: ({
+ pagination,
+ }: {
+ pagination: { offset: number; limit: number; count: number; total: number };
+ }) => {
+ const total = pagination.offset + pagination.count;
+
+ if (total >= pagination.total) {
+ return undefined;
+ }
+
+ return pagination.offset + mediaItemsLimit;
+ },
+ }
+ );
+
+ const mediaItems = data?.pages.flatMap((page) => page.media) ?? [];
+
+ return { mediaItems, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage };
+};
diff --git a/application/ui/src/features/inspect/dataset/media-preview/sidebar-items/sidebar-items.component.tsx b/application/ui/src/features/inspect/dataset/media-preview/sidebar-items/sidebar-items.component.tsx
index 2cc49eaa2c..515037e4db 100644
--- a/application/ui/src/features/inspect/dataset/media-preview/sidebar-items/sidebar-items.component.tsx
+++ b/application/ui/src/features/inspect/dataset/media-preview/sidebar-items/sidebar-items.component.tsx
@@ -1,8 +1,9 @@
import { Selection, Size, View } from '@geti/ui';
-import { MediaThumbnail } from 'src/components/media-thumbnail/media-thumbnail.component';
-import { GridMediaItem } from 'src/components/virtualizer-grid-layout/grid-media-item/grid-media-item.component';
-import { VirtualizerGridLayout } from 'src/components/virtualizer-grid-layout/virtualizer-grid-layout.component';
+import { getThumbnailUrl } from 'src/features/inspect/utils';
+import { GridMediaItem } from '../../../../..//components/virtualizer-grid-layout/grid-media-item/grid-media-item.component';
+import { MediaThumbnail } from '../../../../../components/media-thumbnail/media-thumbnail.component';
+import { VirtualizerGridLayout } from '../../../../../components/virtualizer-grid-layout/virtualizer-grid-layout.component';
import { MediaItem } from '../../types';
interface SidebarItemsProps {
@@ -19,9 +20,6 @@ const layoutOptions = {
preserveAspectRatio: true,
};
-const getThumbnailUrl = (mediaItem: MediaItem) =>
- `/api/projects/${mediaItem.project_id}/images/${mediaItem.id}/thumbnail`;
-
export const SidebarItems = ({ mediaItems, selectedMediaItem, onSelectedMediaItem }: SidebarItemsProps) => {
const selectedIndex = mediaItems.findIndex((item) => item.id === selectedMediaItem.id);
diff --git a/application/ui/src/features/inspect/projects-management/add-project-button/add-project-button.component.tsx b/application/ui/src/features/inspect/projects-management/add-project-button/add-project-button.component.tsx
new file mode 100644
index 0000000000..b3753933aa
--- /dev/null
+++ b/application/ui/src/features/inspect/projects-management/add-project-button/add-project-button.component.tsx
@@ -0,0 +1,52 @@
+/**
+ * Copyright (C) 2025 Intel Corporation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { $api } from '@geti-inspect/api';
+import { ActionButton, Text } from '@geti/ui';
+import { AddCircle } from '@geti/ui/icons';
+import { v4 as uuid } from 'uuid';
+
+import styles from './add-project-button.module.scss';
+
+interface AddProjectProps {
+ onSetProjectInEdition: (projectId: string) => void;
+ projectsCount: number;
+}
+
+export const AddProjectButton = ({ onSetProjectInEdition, projectsCount }: AddProjectProps) => {
+ const addProjectMutation = $api.useMutation('post', '/api/projects', {
+ meta: {
+ invalidates: [['get', '/api/projects']],
+ },
+ });
+
+ const addProject = () => {
+ const newProjectId = uuid();
+ const newProjectName = `Project #${projectsCount + 1}`;
+
+ addProjectMutation.mutate({
+ body: {
+ id: newProjectId,
+ name: newProjectName,
+ },
+ });
+
+ onSetProjectInEdition(newProjectId);
+ };
+
+ return (
+
+
+ Add project
+
+ );
+};
diff --git a/application/ui/src/features/inspect/projects-management/add-project-button/add-project-button.module.scss b/application/ui/src/features/inspect/projects-management/add-project-button/add-project-button.module.scss
new file mode 100644
index 0000000000..d1113233ff
--- /dev/null
+++ b/application/ui/src/features/inspect/projects-management/add-project-button/add-project-button.module.scss
@@ -0,0 +1,8 @@
+.addProjectButton {
+ color: var(--spectrum-global-color-gray-900);
+
+ & span[class*='spectrum-ActionButton-label'] {
+ text-align: start;
+ margin-left: var(--spectrum-global-dimension-size-100) !important;
+ }
+}
diff --git a/application/ui/src/features/inspect/projects-management/project-list-item/project-edition/project-edition.component.tsx b/application/ui/src/features/inspect/projects-management/project-list-item/project-edition/project-edition.component.tsx
new file mode 100644
index 0000000000..f6cca6c5b3
--- /dev/null
+++ b/application/ui/src/features/inspect/projects-management/project-list-item/project-edition/project-edition.component.tsx
@@ -0,0 +1,56 @@
+/**
+ * Copyright (C) 2025 Intel Corporation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useEffect, useRef, useState } from 'react';
+
+import { Loading, TextField, type TextFieldRef } from '@geti/ui';
+
+interface ProjectEditionProps {
+ name: string;
+ isPending: boolean;
+ onChange: (newName: string) => void;
+}
+
+export const ProjectEdition = ({ name, isPending, onChange }: ProjectEditionProps) => {
+ const textFieldRef = useRef(null);
+ const [newName, setNewName] = useState(name);
+
+ const handleBlur = () => {
+ onChange(newName);
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ onChange(newName);
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ setNewName(name);
+ onChange(name);
+ }
+ };
+
+ useEffect(() => {
+ textFieldRef.current?.select();
+ }, []);
+
+ return (
+ <>
+
+ {isPending && }
+ >
+ );
+};
diff --git a/application/ui/src/features/inspect/projects-management/project-list-item/project-edition/project-edition.test.tsx b/application/ui/src/features/inspect/projects-management/project-list-item/project-edition/project-edition.test.tsx
new file mode 100644
index 0000000000..d2a90f78ae
--- /dev/null
+++ b/application/ui/src/features/inspect/projects-management/project-list-item/project-edition/project-edition.test.tsx
@@ -0,0 +1,90 @@
+/**
+ * Copyright (C) 2025 Intel Corporation
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ProjectEdition } from './project-edition.component';
+
+describe('ProjectEdition', () => {
+ const defaultProps = {
+ name: 'Test Project',
+ isPending: false,
+ onChange: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders with the initial project name', () => {
+ render();
+
+ const input = screen.getByRole('textbox', { name: /edit project name/i });
+ expect(input).toHaveValue('Test Project');
+ });
+
+ it('auto-selects the text field on mount', async () => {
+ render();
+
+ const input = screen.getByRole('textbox', { name: /edit project name/i }) as HTMLInputElement;
+
+ await waitFor(() => {
+ expect(input.selectionStart).toBe(0);
+ expect(input.selectionEnd).toBe(defaultProps.name.length);
+ });
+ });
+
+ it('calls onBlur with new name when input loses focus', async () => {
+ const onChange = vi.fn();
+
+ render();
+
+ const input = screen.getByRole('textbox', { name: /edit project name/i });
+
+ await userEvent.clear(input);
+ await userEvent.type(input, 'New Project Name');
+ await userEvent.tab();
+
+ expect(onChange).toHaveBeenCalledWith('New Project Name');
+ });
+
+ it('calls onBlur with new name when Enter key is pressed', async () => {
+ const onChange = vi.fn();
+
+ render();
+
+ const input = screen.getByRole('textbox', { name: /edit project name/i });
+
+ await userEvent.clear(input);
+ await userEvent.type(input, 'New Project Name');
+ await userEvent.keyboard('{Enter}');
+
+ expect(onChange).toHaveBeenCalledWith('New Project Name');
+ });
+
+ it('resets to original name and calls onBlur when Escape key is pressed', async () => {
+ const onChange = vi.fn();
+
+ render();
+
+ const input = screen.getByRole('textbox', { name: /edit project name/i });
+
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Modified Name');
+ await userEvent.keyboard('{Escape}');
+
+ expect(input).toHaveValue('Test Project');
+ expect(onChange).toHaveBeenCalledWith('Test Project');
+ });
+
+ it('disables the input when isPending is true', () => {
+ render();
+
+ const input = screen.getByRole('textbox', { name: /edit project name/i });
+ expect(input).toBeDisabled();
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+});
diff --git a/application/ui/src/features/inspect/projects-management/project-list-item/project-list-actions/project-list-actions.component.tsx b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-actions/project-list-actions.component.tsx
new file mode 100644
index 0000000000..bdfcf219cc
--- /dev/null
+++ b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-actions/project-list-actions.component.tsx
@@ -0,0 +1,32 @@
+import { Key } from 'react';
+
+import { ActionButton, Item, Menu, MenuTrigger } from '@geti/ui';
+import { MoreMenu } from 'packages/ui/icons';
+
+interface ProjectActionsProps {
+ onRename: () => void;
+}
+
+const PROJECT_ACTIONS = {
+ RENAME: 'Rename',
+};
+
+export const ProjectActions = ({ onRename }: ProjectActionsProps) => {
+ const handleAction = (key: Key) => {
+ if (key === PROJECT_ACTIONS.RENAME) {
+ onRename();
+ }
+ };
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/application/ui/src/features/inspect/projects-management/project-list-item/project-list-actions/project-list-actions.test.tsx b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-actions/project-list-actions.test.tsx
new file mode 100644
index 0000000000..6b018f11d2
--- /dev/null
+++ b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-actions/project-list-actions.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TestProviders } from 'src/providers';
+
+import { ProjectActions } from './project-list-actions.component';
+
+describe('ProjectActions', () => {
+ it('displays rename option in the menu', async () => {
+ render(
+
+
+
+ );
+
+ await userEvent.click(screen.getByRole('button'));
+
+ expect(screen.getByRole('menuitem', { name: /Rename/i })).toBeVisible();
+ });
+
+ it('calls onRename when rename menu item is clicked', async () => {
+ const mockOnRename = vi.fn();
+ render(
+
+
+
+ );
+
+ await userEvent.click(screen.getByRole('button'));
+ await userEvent.click(screen.getByRole('menuitem', { name: /Rename/i }));
+
+ expect(mockOnRename).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.component.tsx b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.component.tsx
index c0bf446b7d..f696f5707f 100644
--- a/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.component.tsx
+++ b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.component.tsx
@@ -3,80 +3,44 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useEffect, useRef, useState } from 'react';
-
+import { $api } from '@geti-inspect/api';
import { SchemaProjectList } from '@geti-inspect/api/spec';
-import { Flex, PhotoPlaceholder, Text, TextField, type TextFieldRef } from '@geti/ui';
+import { Flex, PhotoPlaceholder, Text } from '@geti/ui';
import { clsx } from 'clsx';
import { useNavigate } from 'react-router';
-import { useWebRTCConnection } from '../../../../components/stream/web-rtc-connection-provider';
import { paths } from '../../../../routes/paths';
+import { ProjectEdition } from './project-edition/project-edition.component';
+import { ProjectActions } from './project-list-actions/project-list-actions.component';
import styles from './project-list-item.module.scss';
export type Project = SchemaProjectList['projects'][number];
-interface ProjectEditionProps {
- onBlur: (newName: string) => void;
- name: string;
-}
-
-const ProjectEdition = ({ name, onBlur }: ProjectEditionProps) => {
- const textFieldRef = useRef(null);
- const [newName, setNewName] = useState(name);
-
- const handleBlur = () => {
- onBlur(newName);
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- onBlur(newName);
- }
-
- if (e.key === 'Escape') {
- e.preventDefault();
- setNewName(name);
- onBlur(name);
- }
- };
-
- useEffect(() => {
- textFieldRef.current?.select();
- }, []);
-
- return (
-
- );
-};
-
interface ProjectListItemProps {
project: Project;
isActive: boolean;
isInEditMode: boolean;
- onBlur: (projectId: string, newName: string) => void;
+ setProjectInEdition: (projectId: string | null) => void;
}
-export const ProjectListItem = ({ project, isInEditMode, isActive, onBlur }: ProjectListItemProps) => {
+export const ProjectListItem = ({ project, isActive, isInEditMode, setProjectInEdition }: ProjectListItemProps) => {
const navigate = useNavigate();
- const { stop } = useWebRTCConnection();
- const handleBlur = (newProjectId?: string) => (newName: string) => {
- if (newProjectId === undefined) {
+ const updateProject = $api.useMutation('patch', '/api/projects/{project_id}', {
+ onSettled: () => setProjectInEdition(null),
+ meta: { invalidates: [['get', '/api/projects']] },
+ });
+
+ const handleNameChange = (projectId?: string) => (newName: string) => {
+ if (projectId === undefined) {
return;
}
- onBlur(newProjectId, newName);
+ updateProject.mutate({
+ params: { path: { project_id: projectId } },
+ body: { name: newName },
+ });
};
const handleNavigateToProject = () => {
@@ -84,7 +48,6 @@ export const ProjectListItem = ({ project, isInEditMode, isActive, onBlur }: Pro
return;
}
- stop();
navigate(`${paths.project({ projectId: project.id })}?mode=Dataset`);
};
@@ -95,7 +58,11 @@ export const ProjectListItem = ({ project, isInEditMode, isActive, onBlur }: Pro
>
{isInEditMode ? (
-
+
) : (
{project.name}
)}
+
+ setProjectInEdition(project.id ?? null)} />
);
diff --git a/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.test.tsx b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.test.tsx
index 87889009f7..96c083d262 100644
--- a/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.test.tsx
+++ b/application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.test.tsx
@@ -1,6 +1,10 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { HttpResponse } from 'msw';
import { useNavigate } from 'react-router';
+import { http } from 'src/api/utils';
+import { server } from 'src/msw-node-setup';
import { vi } from 'vitest';
import { useWebRTCConnection } from '../../../../components/stream/web-rtc-connection-provider';
@@ -16,7 +20,6 @@ vi.mock('react-router', async () => {
};
});
-const mockStop = vi.fn();
const mockNavigate = vi.fn();
describe('ProjectListItem', () => {
@@ -29,7 +32,7 @@ describe('ProjectListItem', () => {
vi.clearAllMocks();
vi.mocked(useNavigate).mockReturnValue(mockNavigate);
vi.mocked(useWebRTCConnection).mockReturnValue({
- stop: mockStop,
+ stop: vi.fn(),
start: vi.fn(),
status: 'idle',
webRTCConnectionRef: { current: null },
@@ -37,11 +40,50 @@ describe('ProjectListItem', () => {
});
it('navigates to project when clicked', async () => {
- render();
+ render(
+
+
+
+ );
await userEvent.click(screen.getByRole('listitem'));
- expect(mockStop).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/projects/project-123?mode=Dataset');
});
+
+ it('updates project name when edited', async () => {
+ const updateNameSpy = vi.fn();
+ const mockedSetProjectInEdition = vi.fn();
+
+ server.use(
+ http.patch('/api/projects/{project_id}', () => {
+ updateNameSpy();
+ return HttpResponse.json({});
+ })
+ );
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole('textbox', { name: /edit project name/i });
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Updated Project Name');
+ await userEvent.tab();
+
+ expect(updateNameSpy).toHaveBeenCalled();
+ expect(mockedSetProjectInEdition).toHaveBeenCalledWith(null);
+ });
});
diff --git a/application/ui/src/features/inspect/projects-management/projects-list-panel.component.tsx b/application/ui/src/features/inspect/projects-management/projects-list-panel.component.tsx
index bf2175d304..df607bdb39 100644
--- a/application/ui/src/features/inspect/projects-management/projects-list-panel.component.tsx
+++ b/application/ui/src/features/inspect/projects-management/projects-list-panel.component.tsx
@@ -18,12 +18,10 @@ import {
Header,
Heading,
PhotoPlaceholder,
- Text,
View,
} from '@geti/ui';
-import { AddCircle } from '@geti/ui/icons';
-import { v4 as uuid } from 'uuid';
+import { AddProjectButton } from './add-project-button/add-project-button.component';
import { ProjectsList } from './projects-list.component';
import styles from './projects-list.module.scss';
@@ -44,47 +42,6 @@ const SelectedProjectButton = ({ name, id }: SelectedProjectProps) => {
);
};
-interface AddProjectProps {
- onSetProjectInEdition: (projectId: string) => void;
- projectsCount: number;
-}
-
-const AddProjectButton = ({ onSetProjectInEdition, projectsCount }: AddProjectProps) => {
- const addProjectMutation = $api.useMutation('post', '/api/projects', {
- meta: {
- invalidates: [['get', '/api/projects']],
- },
- });
-
- const addProject = () => {
- const newProjectId = uuid();
- const newProjectName = `Project #${projectsCount + 1}`;
-
- addProjectMutation.mutate({
- body: {
- id: newProjectId,
- name: newProjectName,
- },
- });
-
- onSetProjectInEdition(newProjectId);
- };
-
- return (
-
-
- Add project
-
- );
-};
-
export const ProjectsListPanel = () => {
const { projectId } = useProjectIdentifier();
const { data } = $api.useSuspenseQuery('get', '/api/projects');
diff --git a/application/ui/src/features/inspect/projects-management/projects-list.component.tsx b/application/ui/src/features/inspect/projects-management/projects-list.component.tsx
index 094b5e03de..d20938e30f 100644
--- a/application/ui/src/features/inspect/projects-management/projects-list.component.tsx
+++ b/application/ui/src/features/inspect/projects-management/projects-list.component.tsx
@@ -3,9 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { $api } from '@geti-inspect/api';
import { useProjectIdentifier } from '@geti-inspect/hooks';
-import { isEmpty } from 'lodash-es';
import { Project, ProjectListItem } from './project-list-item/project-list-item.component';
@@ -23,33 +21,13 @@ export const ProjectsList = ({ projects, setProjectInEdition, projectIdInEdition
return projectIdInEdition === projectId;
};
- const updateProject = $api.useMutation('patch', '/api/projects/{project_id}', {
- meta: {
- invalidates: [['get', '/api/projects']],
- },
- });
-
- const handleBlur = (projectId: string, newName: string) => {
- setProjectInEdition(null);
-
- const projectToUpdate = projects.find((project) => project.id === projectId);
- if (projectToUpdate?.name === newName || isEmpty(newName.trim())) {
- return;
- }
-
- updateProject.mutate({
- params: { path: { project_id: projectId } },
- body: { name: newName },
- });
- };
-
return (
{projects.map((project) => (
diff --git a/application/ui/src/features/inspect/projects-management/projects-list.module.scss b/application/ui/src/features/inspect/projects-management/projects-list.module.scss
index 2358cc2518..6bfa13cfcd 100644
--- a/application/ui/src/features/inspect/projects-management/projects-list.module.scss
+++ b/application/ui/src/features/inspect/projects-management/projects-list.module.scss
@@ -2,15 +2,6 @@
padding-block-start: 0;
}
-.addProjectButton {
- color: var(--spectrum-global-color-gray-900);
-
- & span[class*='spectrum-ActionButton-label'] {
- text-align: start;
- margin-left: var(--spectrum-global-dimension-size-100) !important;
- }
-}
-
.dialog {
& div[class*='spectrum-Dialog-grid'] {
--spectrum-dialog-padding-x: 0px;
diff --git a/application/ui/src/features/inspect/sidebar.component.tsx b/application/ui/src/features/inspect/sidebar.component.tsx
index 781f2d6ba1..5b2aca6c38 100644
--- a/application/ui/src/features/inspect/sidebar.component.tsx
+++ b/application/ui/src/features/inspect/sidebar.component.tsx
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { Dataset as DatasetIcon, Models as ModelsIcon, Stats } from '@geti-inspect/icons';
+import { Dataset as DatasetIcon, Models as ModelsIcon } from '@geti-inspect/icons';
import { Flex, Grid, ToggleButton, View } from '@geti/ui';
import { useSearchParams } from 'react-router-dom';
@@ -15,7 +15,9 @@ import styles from './sidebar.module.scss';
const TABS = [
{ label: 'Dataset', icon: , content: },
{ label: 'Models', icon: , content: },
- { label: 'Stats', icon: , content: <>Stats> },
+ /* TODO: Add Stats tab implementation. See GitHub issue
+ https://github.com/open-edge-platform/anomalib/issues/3173 */
+ /* { label: 'Stats', icon: , content: <>Stats> }, */
];
interface TabProps {
diff --git a/application/ui/src/features/inspect/toolbar/inference-devices/inference-devices.component.tsx b/application/ui/src/features/inspect/toolbar/inference-devices/inference-devices.component.tsx
index 874a10319d..af981028fb 100644
--- a/application/ui/src/features/inspect/toolbar/inference-devices/inference-devices.component.tsx
+++ b/application/ui/src/features/inspect/toolbar/inference-devices/inference-devices.component.tsx
@@ -45,6 +45,7 @@ export const InferenceDevices = () => {
isQuiet
width='auto'
label='Inference devices: '
+ aria-label='inference devices'
labelAlign='end'
labelPosition='side'
items={options}
diff --git a/application/ui/src/features/inspect/toolbar/models-list/models-list.component.tsx b/application/ui/src/features/inspect/toolbar/models-list/models-list.component.tsx
index 0715f01be0..05045b665c 100644
--- a/application/ui/src/features/inspect/toolbar/models-list/models-list.component.tsx
+++ b/application/ui/src/features/inspect/toolbar/models-list/models-list.component.tsx
@@ -38,6 +38,7 @@ export const ModelsList = () => {
key={model.id}
variant='secondary'
onPress={() => handleSelectionChange(String(model.id))}
+ height={'size-800'}
isPending={patchPipeline.isPending}
isDisabled={patchPipeline.isPending}
UNSAFE_className={clsx(classes.option, { [classes.active]: model.id === selectedModelId })}
diff --git a/application/ui/src/features/inspect/toolbar/sinks/sink-list/sink-list.component.tsx b/application/ui/src/features/inspect/toolbar/sinks/sink-list/sink-list.component.tsx
index 0cf842734b..18c3e54dc3 100644
--- a/application/ui/src/features/inspect/toolbar/sinks/sink-list/sink-list.component.tsx
+++ b/application/ui/src/features/inspect/toolbar/sinks/sink-list/sink-list.component.tsx
@@ -1,7 +1,7 @@
import { Add as AddIcon } from '@geti/ui/icons';
import { clsx } from 'clsx';
import { isEqual } from 'lodash-es';
-import { Button, Flex, Loading, Text, VirtualizedListLayout } from 'packages/ui';
+import { Button, Flex, Loading, Text, View, VirtualizedListLayout } from 'packages/ui';
import { StatusTag } from '../../../../../components/status-tag/status-tag.component';
import { usePipeline } from '../../../../../hooks/use-pipeline.hook';
@@ -68,25 +68,30 @@ export const SinkList = ({ sinks, isLoading, onLoadMore, onAddSink, onEditSink }
const currentSinkId = pipeline.data.sink?.id;
return (
-
-