Skip to content

Commit b48d2f6

Browse files
committed
rename project
Signed-off-by: Colorado, Camilo <camilo.colorado@intel.com>
1 parent 6a9413f commit b48d2f6

File tree

11 files changed

+342
-129
lines changed

11 files changed

+342
-129
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Copyright (C) 2025 Intel Corporation
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { $api } from '@geti-inspect/api';
7+
import { ActionButton, Text } from '@geti/ui';
8+
import { AddCircle } from '@geti/ui/icons';
9+
import { v4 as uuid } from 'uuid';
10+
11+
import styles from './add-project-button.module.scss';
12+
13+
interface AddProjectProps {
14+
onSetProjectInEdition: (projectId: string) => void;
15+
projectsCount: number;
16+
}
17+
18+
export const AddProjectButton = ({ onSetProjectInEdition, projectsCount }: AddProjectProps) => {
19+
const addProjectMutation = $api.useMutation('post', '/api/projects', {
20+
meta: {
21+
invalidates: [['get', '/api/projects']],
22+
},
23+
});
24+
25+
const addProject = () => {
26+
const newProjectId = uuid();
27+
const newProjectName = `Project #${projectsCount + 1}`;
28+
29+
addProjectMutation.mutate({
30+
body: {
31+
id: newProjectId,
32+
name: newProjectName,
33+
},
34+
});
35+
36+
onSetProjectInEdition(newProjectId);
37+
};
38+
39+
return (
40+
<ActionButton
41+
isQuiet
42+
width={'100%'}
43+
marginStart={'size-100'}
44+
marginEnd={'size-350'}
45+
UNSAFE_className={styles.addProjectButton}
46+
onPress={addProject}
47+
>
48+
<AddCircle />
49+
<Text marginX='size-50'>Add project</Text>
50+
</ActionButton>
51+
);
52+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.addProjectButton {
2+
color: var(--spectrum-global-color-gray-900);
3+
4+
& span[class*='spectrum-ActionButton-label'] {
5+
text-align: start;
6+
margin-left: var(--spectrum-global-dimension-size-100) !important;
7+
}
8+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Copyright (C) 2025 Intel Corporation
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { useEffect, useRef, useState } from 'react';
7+
8+
import { Loading, TextField, type TextFieldRef } from '@geti/ui';
9+
10+
interface ProjectEditionProps {
11+
name: string;
12+
isPending: boolean;
13+
onChange: (newName: string) => void;
14+
}
15+
16+
export const ProjectEdition = ({ name, isPending, onChange }: ProjectEditionProps) => {
17+
const textFieldRef = useRef<TextFieldRef>(null);
18+
const [newName, setNewName] = useState<string>(name);
19+
20+
const handleBlur = () => {
21+
onChange(newName);
22+
};
23+
24+
const handleKeyDown = (event: React.KeyboardEvent) => {
25+
if (event.key === 'Enter') {
26+
event.preventDefault();
27+
onChange(newName);
28+
}
29+
30+
if (event.key === 'Escape') {
31+
event.preventDefault();
32+
setNewName(name);
33+
onChange(name);
34+
}
35+
};
36+
37+
useEffect(() => {
38+
textFieldRef.current?.select();
39+
}, []);
40+
41+
return (
42+
<>
43+
<TextField
44+
isQuiet
45+
ref={textFieldRef}
46+
value={newName}
47+
onBlur={handleBlur}
48+
onChange={setNewName}
49+
onKeyDown={handleKeyDown}
50+
isDisabled={isPending}
51+
aria-label='Edit project name'
52+
/>
53+
{isPending && <Loading mode={'inline'} size='S' />}
54+
</>
55+
);
56+
};
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright (C) 2025 Intel Corporation
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { render, screen, waitFor } from '@testing-library/react';
7+
import userEvent from '@testing-library/user-event';
8+
9+
import { ProjectEdition } from './project-edition.component';
10+
11+
describe('ProjectEdition', () => {
12+
const defaultProps = {
13+
name: 'Test Project',
14+
isPending: false,
15+
onChange: vi.fn(),
16+
};
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
it('renders with the initial project name', () => {
23+
render(<ProjectEdition {...defaultProps} />);
24+
25+
const input = screen.getByRole('textbox', { name: /edit project name/i });
26+
expect(input).toHaveValue('Test Project');
27+
});
28+
29+
it('auto-selects the text field on mount', async () => {
30+
render(<ProjectEdition {...defaultProps} />);
31+
32+
const input = screen.getByRole('textbox', { name: /edit project name/i }) as HTMLInputElement;
33+
34+
await waitFor(() => {
35+
expect(input.selectionStart).toBe(0);
36+
expect(input.selectionEnd).toBe(defaultProps.name.length);
37+
});
38+
});
39+
40+
it('calls onBlur with new name when input loses focus', async () => {
41+
const onChange = vi.fn();
42+
43+
render(<ProjectEdition {...defaultProps} onChange={onChange} />);
44+
45+
const input = screen.getByRole('textbox', { name: /edit project name/i });
46+
47+
await userEvent.clear(input);
48+
await userEvent.type(input, 'New Project Name');
49+
await userEvent.tab();
50+
51+
expect(onChange).toHaveBeenCalledWith('New Project Name');
52+
});
53+
54+
it('calls onBlur with new name when Enter key is pressed', async () => {
55+
const onChange = vi.fn();
56+
57+
render(<ProjectEdition {...defaultProps} onChange={onChange} />);
58+
59+
const input = screen.getByRole('textbox', { name: /edit project name/i });
60+
61+
await userEvent.clear(input);
62+
await userEvent.type(input, 'New Project Name');
63+
await userEvent.keyboard('{Enter}');
64+
65+
expect(onChange).toHaveBeenCalledWith('New Project Name');
66+
});
67+
68+
it('resets to original name and calls onBlur when Escape key is pressed', async () => {
69+
const onChange = vi.fn();
70+
71+
render(<ProjectEdition {...defaultProps} onChange={onChange} />);
72+
73+
const input = screen.getByRole('textbox', { name: /edit project name/i });
74+
75+
await userEvent.clear(input);
76+
await userEvent.type(input, 'Modified Name');
77+
await userEvent.keyboard('{Escape}');
78+
79+
expect(input).toHaveValue('Test Project');
80+
expect(onChange).toHaveBeenCalledWith('Test Project');
81+
});
82+
83+
it('disables the input when isPending is true', () => {
84+
render(<ProjectEdition {...defaultProps} isPending={true} />);
85+
86+
const input = screen.getByRole('textbox', { name: /edit project name/i });
87+
expect(input).toBeDisabled();
88+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
89+
});
90+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Key } from 'react';
2+
3+
import { ActionButton, Item, Menu, MenuTrigger } from '@geti/ui';
4+
import { MoreMenu } from 'packages/ui/icons';
5+
6+
interface ProjectActionsProps {
7+
onRename: () => void;
8+
}
9+
10+
const PROJECT_ACTIONS = {
11+
RENAME: 'Rename',
12+
};
13+
14+
export const ProjectActions = ({ onRename }: ProjectActionsProps) => {
15+
const handleAction = (key: Key) => {
16+
if (key === PROJECT_ACTIONS.RENAME) {
17+
onRename();
18+
}
19+
};
20+
return (
21+
<MenuTrigger>
22+
<ActionButton isQuiet>
23+
<MoreMenu />
24+
</ActionButton>
25+
<Menu onAction={handleAction}>
26+
{[PROJECT_ACTIONS.RENAME].map((action) => (
27+
<Item key={action}>{action}</Item>
28+
))}
29+
</Menu>
30+
</MenuTrigger>
31+
);
32+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { TestProviders } from 'src/providers';
4+
5+
import { ProjectActions } from './project-list-actions.component';
6+
7+
describe('ProjectActions', () => {
8+
it('displays rename option in the menu', async () => {
9+
render(
10+
<TestProviders>
11+
<ProjectActions onRename={vi.fn()} />
12+
</TestProviders>
13+
);
14+
15+
await userEvent.click(screen.getByRole('button'));
16+
17+
expect(screen.getByRole('menuitem', { name: /Rename/i })).toBeVisible();
18+
});
19+
20+
it('calls onRename when rename menu item is clicked', async () => {
21+
const mockOnRename = vi.fn();
22+
render(
23+
<TestProviders>
24+
<ProjectActions onRename={mockOnRename} />
25+
</TestProviders>
26+
);
27+
28+
await userEvent.click(screen.getByRole('button'));
29+
await userEvent.click(screen.getByRole('menuitem', { name: /Rename/i }));
30+
31+
expect(mockOnRename).toHaveBeenCalledTimes(1);
32+
});
33+
});

application/ui/src/features/inspect/projects-management/project-list-item/project-list-item.component.tsx

Lines changed: 24 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,80 +3,46 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { useEffect, useRef, useState } from 'react';
7-
6+
import { $api } from '@geti-inspect/api';
87
import { SchemaProjectList } from '@geti-inspect/api/spec';
9-
import { Flex, PhotoPlaceholder, Text, TextField, type TextFieldRef } from '@geti/ui';
8+
import { Flex, PhotoPlaceholder, Text } from '@geti/ui';
109
import { clsx } from 'clsx';
1110
import { useNavigate } from 'react-router';
1211

1312
import { useWebRTCConnection } from '../../../../components/stream/web-rtc-connection-provider';
1413
import { paths } from '../../../../routes/paths';
14+
import { ProjectEdition } from './project-edition/project-edition.component';
15+
import { ProjectActions } from './project-list-actions/project-list-actions.component';
1516

1617
import styles from './project-list-item.module.scss';
1718

1819
export type Project = SchemaProjectList['projects'][number];
1920

20-
interface ProjectEditionProps {
21-
onBlur: (newName: string) => void;
22-
name: string;
23-
}
24-
25-
const ProjectEdition = ({ name, onBlur }: ProjectEditionProps) => {
26-
const textFieldRef = useRef<TextFieldRef>(null);
27-
const [newName, setNewName] = useState<string>(name);
28-
29-
const handleBlur = () => {
30-
onBlur(newName);
31-
};
32-
33-
const handleKeyDown = (e: React.KeyboardEvent) => {
34-
if (e.key === 'Enter') {
35-
e.preventDefault();
36-
onBlur(newName);
37-
}
38-
39-
if (e.key === 'Escape') {
40-
e.preventDefault();
41-
setNewName(name);
42-
onBlur(name);
43-
}
44-
};
45-
46-
useEffect(() => {
47-
textFieldRef.current?.select();
48-
}, []);
49-
50-
return (
51-
<TextField
52-
isQuiet
53-
ref={textFieldRef}
54-
value={newName}
55-
onBlur={handleBlur}
56-
onKeyDown={handleKeyDown}
57-
onChange={setNewName}
58-
aria-label='Edit project name'
59-
/>
60-
);
61-
};
62-
6321
interface ProjectListItemProps {
6422
project: Project;
6523
isActive: boolean;
6624
isInEditMode: boolean;
67-
onBlur: (projectId: string, newName: string) => void;
25+
setProjectInEdition: (projectId: string | null) => void;
6826
}
6927

70-
export const ProjectListItem = ({ project, isInEditMode, isActive, onBlur }: ProjectListItemProps) => {
28+
export const ProjectListItem = ({ project, isActive, isInEditMode, setProjectInEdition }: ProjectListItemProps) => {
7129
const navigate = useNavigate();
7230
const { stop } = useWebRTCConnection();
7331

74-
const handleBlur = (newProjectId?: string) => (newName: string) => {
75-
if (newProjectId === undefined) {
32+
const updateProject = $api.useMutation('patch', '/api/projects/{project_id}', {
33+
onSettled: () => setProjectInEdition(null),
34+
meta: { invalidates: [['get', '/api/projects']] },
35+
});
36+
37+
const handleNameChange = (projectId?: string) => (newName: string) => {
38+
if (projectId === undefined) {
7639
return;
7740
}
7841

79-
onBlur(newProjectId, newName);
42+
updateProject.mutate({
43+
params: { path: { project_id: projectId } },
44+
body: { name: newName },
45+
});
8046
};
8147

8248
const handleNavigateToProject = () => {
@@ -95,7 +61,11 @@ export const ProjectListItem = ({ project, isInEditMode, isActive, onBlur }: Pro
9561
>
9662
<Flex justifyContent='space-between' alignItems='center' marginX={'size-200'}>
9763
{isInEditMode ? (
98-
<ProjectEdition name={project.name} onBlur={handleBlur(project.id)} />
64+
<ProjectEdition
65+
name={project.name}
66+
onChange={handleNameChange(project.id)}
67+
isPending={updateProject.isPending}
68+
/>
9969
) : (
10070
<Flex alignItems={'center'} gap={'size-100'}>
10171
<PhotoPlaceholder
@@ -107,6 +77,8 @@ export const ProjectListItem = ({ project, isInEditMode, isActive, onBlur }: Pro
10777
<Text>{project.name}</Text>
10878
</Flex>
10979
)}
80+
81+
<ProjectActions onRename={() => setProjectInEdition(project.id ?? null)} />
11082
</Flex>
11183
</li>
11284
);

0 commit comments

Comments
 (0)