Skip to content

Commit e47c927

Browse files
Pr3 add join leave UI buttons (#2795)
* fix: retrack PR2 based on PR1 * fix: lint errors in test_projects.py * fix: update test assertions to include is_public field * style: apply pre-commit formatting fixes * Add project visibility update API endpoint * Fix user autocomplete: allow all users to see user list * Fix project deletion permissions: allow project owners to delete their projects * Apply pre-commit formatting fixes to PR2 * Fix project deletion permissions and member race conditions * Allow public project access to gateways endpoints * fix: address PR comments from r4victor * feat: generalize project update endpoint * refactor: rename /update_visibility to /update for more generic project updates * Remove redundant comments that duplicate code logic * Remove only redundant comments added in PR2 * Add join/leave project UI functionality * Add Join/Leave button to project header and restrict CLI section to members * refactor: improve code style and extract member actions hook * refactor: use useProjectMemberActions in ProjectDetails * Add join/leave project UI functionality * Add Join/Leave button to project header and restrict CLI section to members * refactor: improve code style and extract member actions hook * refactor: use useProjectMemberActions in ProjectDetails * Fix linter errors and remove obsolete updateProjectVisibility mutation * Move project visibility to Danger Zone with ComboBox and add visibility column * Fix: Hide 'Leave Project' button for project owners * Hide Leave Project button for last admin instead of showing error * Fix project visibility display issue * Minor changes: - UI captions - Added confirmation on Leave - Removed Join/Leave from Project Details Header --------- Co-authored-by: peterschmidt85 <andrey.cheptsov@gmail.com>
1 parent 0173a48 commit e47c927

File tree

22 files changed

+931
-185
lines changed

22 files changed

+931
-185
lines changed

frontend/src/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export const API = {
5656
DETAILS: (name: IProject['project_name']) => `${API.PROJECTS.BASE()}/${name}`,
5757
DETAILS_INFO: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/get`,
5858
SET_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/set_members`,
59+
ADD_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/add_members`,
60+
REMOVE_MEMBERS: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/remove_members`,
61+
UPDATE: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/update`,
5962

6063
// Repos
6164
REPOS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/repos`,

frontend/src/components/ButtonWithConfirmation/index.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { ConfirmationDialog } from '../ConfirmationDialog';
66

77
import { IProps } from './types';
88

9-
export const ButtonWithConfirmation: React.FC<IProps> = ({ confirmTitle, confirmContent, onClick, ...props }) => {
9+
export const ButtonWithConfirmation: React.FC<IProps> = ({
10+
confirmTitle,
11+
confirmContent,
12+
onClick,
13+
confirmButtonLabel,
14+
...props
15+
}) => {
1016
const [showDeleteConfirm, setShowConfirmDelete] = useState(false);
1117

1218
const toggleDeleteConfirm = () => {
@@ -31,6 +37,7 @@ export const ButtonWithConfirmation: React.FC<IProps> = ({ confirmTitle, confirm
3137
onConfirm={onConfirm}
3238
title={confirmTitle}
3339
content={content}
40+
confirmButtonLabel={confirmButtonLabel}
3441
/>
3542
</>
3643
);

frontend/src/components/ButtonWithConfirmation/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ import type { IProps as ButtonProps } from '../Button';
55
export interface IProps extends Omit<ButtonProps, 'onClick'> {
66
confirmTitle?: string;
77
confirmContent?: ReactNode;
8+
confirmButtonLabel?: string;
89
onClick?: () => void;
910
}

frontend/src/locale/en.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,14 @@
172172
"runs": "Runs",
173173
"tags": "Tags",
174174
"settings": "Settings",
175+
"join": "Join",
176+
"leave_confirm_title": "Leave project",
177+
"leave_confirm_message": "Are you sure you want to leave this project?",
178+
"leave": "Leave",
179+
"join_success": "Successfully joined the project",
180+
"leave_success": "Successfully left the project",
181+
"join_error": "Failed to join project",
182+
"leave_error": "Failed to leave project",
175183
"card": {
176184
"backend": "Backend",
177185
"settings": "Settings"
@@ -181,6 +189,8 @@
181189
"project_name": "Project name",
182190
"owner": "Owner",
183191
"project_name_description": "Only latin characters, dashes, underscores, and digits",
192+
"is_public": "Make project public",
193+
"is_public_description": "Public projects can be accessed by any user without being a member",
184194
"backend": "Backend",
185195
"backend_config": "Backend config",
186196
"backend_config_description": "Specify the backend config in the YAML format. Click Info for examples.",
@@ -189,6 +199,13 @@
189199
"members_empty_message_title": "No members",
190200
"members_empty_message_text": "Select project's members",
191201
"update_members_success": "Members are updated",
202+
"update_visibility_success": "Project visibility updated successfully",
203+
"update_visibility_confirm_title": "Change project visibility",
204+
"update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.",
205+
"change_visibility": "Change visibility",
206+
"project_visibility": "Project visibility",
207+
"project_visibility_description": "Control who can access this project",
208+
"make_project_public": "Make project public",
192209
"delete_project_confirm_title": "Delete project",
193210
"delete_project_confirm_message": "Are you sure you want to delete this project?",
194211
"delete_projects_confirm_title": "Delete projects",
@@ -282,6 +299,10 @@
282299
"error_notification": "Update project error",
283300
"validation": {
284301
"user_name_format": "Only letters, numbers, - or _"
302+
},
303+
"visibility": {
304+
"private": "Private",
305+
"public": "Public"
285306
}
286307
},
287308
"create": {

frontend/src/pages/Project/Details/Settings/index.tsx

Lines changed: 127 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useMemo } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { useNavigate, useParams } from 'react-router-dom';
44
import { debounce } from 'lodash';
@@ -17,18 +17,21 @@ import {
1717
SelectCSD,
1818
SpaceBetween,
1919
StatusIndicator,
20+
Toggle,
2021
} from 'components';
2122
import { HotspotIds } from 'layouts/AppLayout/TutorialPanel/constants';
2223

2324
import { useBreadcrumbs, useHelpPanel, useNotifications } from 'hooks';
2425
import { riseRouterException } from 'libs';
2526
import { ROUTES } from 'routes';
26-
import { useGetProjectQuery, useUpdateProjectMembersMutation } from 'services/project';
27+
import { useGetProjectQuery, useUpdateProjectMembersMutation, useUpdateProjectMutation } from 'services/project';
2728

2829
import { useCheckAvailableProjectPermission } from 'pages/Project/hooks/useCheckAvailableProjectPermission';
2930
import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand';
3031
import { useDeleteProject } from 'pages/Project/hooks/useDeleteProject';
3132
import { ProjectMembers } from 'pages/Project/Members';
33+
import { getProjectRoleByUserName } from 'pages/Project/utils';
34+
import { useGetUserDataQuery } from 'services/user';
3235

3336
import { useBackendsTable } from '../../Backends/hooks';
3437
import { BackendsTable } from '../../Backends/Table';
@@ -51,23 +54,40 @@ export const ProjectSettings: React.FC = () => {
5154

5255
const [pushNotification] = useNotifications();
5356
const [updateProjectMembers] = useUpdateProjectMembersMutation();
57+
const [updateProject] = useUpdateProjectMutation();
5458
const { deleteProject, isDeleting } = useDeleteProject();
59+
const { data: currentUser } = useGetUserDataQuery({});
5560

5661
const { data, isLoading, error } = useGetProjectQuery({ name: paramProjectName });
5762

5863
useEffect(() => {
59-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
60-
// @ts-ignore
61-
if (error?.status === 404) {
64+
if (error && 'status' in error && error.status === 404) {
6265
riseRouterException();
6366
}
6467
}, [error]);
6568

69+
const currentUserRole = data ? getProjectRoleByUserName(data, currentUser?.username ?? '') : null;
70+
const isProjectMember = currentUserRole !== null;
71+
6672
const currentOwner = {
6773
label: data?.owner.username,
6874
value: data?.owner.username,
6975
};
7076

77+
const visibilityOptions = [
78+
{ label: t('projects.edit.visibility.private') || '', value: 'private' },
79+
{ label: t('projects.edit.visibility.public') || '', value: 'public' },
80+
];
81+
82+
const currentVisibility = data?.isPublic ? 'public' : 'private';
83+
const [selectedVisibility, setSelectedVisibility] = useState(
84+
data?.isPublic ? visibilityOptions[1] : visibilityOptions[0]
85+
);
86+
87+
useEffect(() => {
88+
setSelectedVisibility(data?.isPublic ? visibilityOptions[1] : visibilityOptions[0]);
89+
}, [data]);
90+
7191
const {
7292
data: backendsData,
7393
isDeleting: isDeletingBackend,
@@ -103,7 +123,7 @@ export const ProjectSettings: React.FC = () => {
103123
content: t('projects.edit.update_members_success'),
104124
});
105125
})
106-
.catch((error) => {
126+
.catch((error: any) => {
107127
pushNotification({
108128
type: 'error',
109129
content: t('common.server_error', { error: error?.data?.detail?.msg }),
@@ -113,14 +133,38 @@ export const ProjectSettings: React.FC = () => {
113133

114134
const debouncedMembersHandler = useCallback(debounce(changeMembersHandler, 1000), []);
115135

136+
const changeVisibilityHandler = (is_public: boolean) => {
137+
updateProject({
138+
project_name: paramProjectName,
139+
is_public: is_public,
140+
})
141+
.unwrap()
142+
.then(() => {
143+
pushNotification({
144+
type: 'success',
145+
content: t('projects.edit.update_visibility_success'),
146+
});
147+
})
148+
.catch((error: any) => {
149+
pushNotification({
150+
type: 'error',
151+
content: t('common.server_error', { error: error?.data?.detail?.msg }),
152+
});
153+
});
154+
};
155+
116156
const isDisabledButtons = useMemo<boolean>(() => {
117157
return isDeleting || !data || !isAvailableDeletingPermission(data);
118158
}, [data, isDeleting, isAvailableDeletingPermission]);
119159

120160
const deleteProjectHandler = () => {
121161
if (!data) return;
122162

123-
deleteProject(data).then(() => navigate(ROUTES.PROJECT.LIST));
163+
deleteProject(data)
164+
.then(() => navigate(ROUTES.PROJECT.LIST))
165+
.catch((error: any) => {
166+
console.error('Delete project failed:', error);
167+
});
124168
};
125169

126170
if (isLoadingPage)
@@ -134,42 +178,44 @@ export const ProjectSettings: React.FC = () => {
134178
<>
135179
{data && backendsData && gatewaysData && (
136180
<SpaceBetween size="l">
137-
<Container
138-
header={
139-
<Header variant="h2" info={<InfoLink onFollow={() => openHelpPanel(CLI_INFO)} />}>
140-
{t('projects.edit.cli')}
141-
</Header>
142-
}
143-
>
144-
<SpaceBetween size="s">
145-
<Box variant="p" color="text-body-secondary">
146-
Run the following commands to set up the CLI for this project
147-
</Box>
148-
149-
<div className={styles.codeWrapper}>
150-
<Hotspot hotspotId={HotspotIds.CONFIGURE_CLI_COMMAND}>
151-
<Code className={styles.code}>{configCliCommand}</Code>
152-
153-
<div className={styles.copy}>
154-
<Popover
155-
dismissButton={false}
156-
position="top"
157-
size="small"
158-
triggerType="custom"
159-
content={<StatusIndicator type="success">{t('common.copied')}</StatusIndicator>}
160-
>
161-
<Button
162-
formAction="none"
163-
iconName="copy"
164-
variant="normal"
165-
onClick={copyCliCommand}
166-
/>
167-
</Popover>
168-
</div>
169-
</Hotspot>
170-
</div>
171-
</SpaceBetween>
172-
</Container>
181+
{isProjectMember && (
182+
<Container
183+
header={
184+
<Header variant="h2" info={<InfoLink onFollow={() => openHelpPanel(CLI_INFO)} />}>
185+
{t('projects.edit.cli')}
186+
</Header>
187+
}
188+
>
189+
<SpaceBetween size="s">
190+
<Box variant="p" color="text-body-secondary">
191+
Run the following commands to set up the CLI for this project
192+
</Box>
193+
194+
<div className={styles.codeWrapper}>
195+
<Hotspot hotspotId={HotspotIds.CONFIGURE_CLI_COMMAND}>
196+
<Code className={styles.code}>{configCliCommand}</Code>
197+
198+
<div className={styles.copy}>
199+
<Popover
200+
dismissButton={false}
201+
position="top"
202+
size="small"
203+
triggerType="custom"
204+
content={<StatusIndicator type="success">{t('common.copied')}</StatusIndicator>}
205+
>
206+
<Button
207+
formAction="none"
208+
iconName="copy"
209+
variant="normal"
210+
onClick={copyCliCommand}
211+
/>
212+
</Popover>
213+
</div>
214+
</Hotspot>
215+
</div>
216+
</SpaceBetween>
217+
</Container>
218+
)}
173219

174220
<BackendsTable
175221
backends={backendsData}
@@ -190,6 +236,7 @@ export const ProjectSettings: React.FC = () => {
190236
members={data.members}
191237
readonly={!isProjectManager(data)}
192238
isAdmin={isProjectAdmin(data)}
239+
project={data}
193240
/>
194241

195242
<Container header={<Header variant="h2">{t('common.danger_zone')}</Header>}>
@@ -216,6 +263,43 @@ export const ProjectSettings: React.FC = () => {
216263
</>
217264
)}
218265

266+
{isAvailableProjectManaging && (
267+
<>
268+
<Box variant="h5" color="text-body-secondary">
269+
{t('projects.edit.project_visibility')}
270+
</Box>
271+
272+
<div>
273+
<ButtonWithConfirmation
274+
variant="danger-normal"
275+
disabled={!isProjectManager(data)}
276+
formAction="none"
277+
onClick={() => changeVisibilityHandler(selectedVisibility.value === 'public')}
278+
confirmTitle={t('projects.edit.update_visibility_confirm_title')}
279+
confirmButtonLabel={t('projects.edit.change_visibility')}
280+
confirmContent={
281+
<SpaceBetween size="s">
282+
<Box variant="p" color="text-body-secondary">
283+
{t('projects.edit.update_visibility_confirm_message')}
284+
</Box>
285+
<div className={styles.dangerSectionField}>
286+
<SelectCSD
287+
options={visibilityOptions}
288+
selectedOption={selectedVisibility}
289+
onChange={(event) => setSelectedVisibility(event.detail.selectedOption as { label: string; value: string })}
290+
expandToViewport={true}
291+
filteringType="auto"
292+
/>
293+
</div>
294+
</SpaceBetween>
295+
}
296+
>
297+
{t('projects.edit.change_visibility')}
298+
</ButtonWithConfirmation>
299+
</div>
300+
</>
301+
)}
302+
219303
<Box variant="h5" color="text-body-secondary">
220304
{t('projects.edit.owner')}
221305
</Box>

frontend/src/pages/Project/Details/index.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
1-
import React from 'react';
2-
import { Outlet, useParams } from 'react-router-dom';
1+
import React, { useMemo } from 'react';
2+
import { Outlet, useNavigate, useParams } from 'react-router-dom';
3+
import { useTranslation } from 'react-i18next';
34

4-
import { ContentLayout, DetailsHeader } from 'components';
5+
import { Button, ContentLayout, DetailsHeader } from 'components';
6+
7+
import { useAppSelector, useNotifications } from 'hooks';
8+
import { selectUserData } from 'App/slice';
9+
import { ROUTES } from 'routes';
10+
import { useGetProjectQuery, useAddProjectMemberMutation, useRemoveProjectMemberMutation } from 'services/project';
11+
import { getProjectRoleByUserName } from '../utils';
12+
import { useProjectMemberActions } from '../hooks/useProjectMemberActions';
513

614
export const ProjectDetails: React.FC = () => {
15+
const { t } = useTranslation();
716
const params = useParams();
17+
const navigate = useNavigate();
818
const paramProjectName = params.projectName ?? '';
19+
const userData = useAppSelector(selectUserData);
20+
const { handleJoinProject, handleLeaveProject, isMemberActionLoading } = useProjectMemberActions();
21+
22+
const { data: project } = useGetProjectQuery({ name: paramProjectName });
23+
24+
const currentUserRole = useMemo(() => {
25+
if (!userData?.username || !project) return null;
26+
return getProjectRoleByUserName(project, userData.username);
27+
}, [project, userData?.username]);
28+
29+
const isProjectOwner = userData?.username === project?.owner.username;
30+
31+
const isMember = currentUserRole !== null;
932

1033
return (
11-
<ContentLayout header={<DetailsHeader title={paramProjectName} />}>
34+
<ContentLayout header={<DetailsHeader title={paramProjectName}/>}>
1235
<Outlet />
1336
</ContentLayout>
1437
);

0 commit comments

Comments
 (0)