Skip to content

Commit 821e634

Browse files
Add join/leave project UI functionality
1 parent 8050846 commit 821e634

9 files changed

Lines changed: 248 additions & 23 deletions

File tree

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_VISIBILITY: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/update_visibility`,
5962

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

frontend/src/locale/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@
172172
"runs": "Runs",
173173
"tags": "Tags",
174174
"settings": "Settings",
175+
"join": "Join Project",
176+
"leave": "Leave Project",
177+
"owner_cannot_leave": "Owner Cannot Leave",
178+
"join_success": "Successfully joined the project",
179+
"leave_success": "Successfully left the project",
180+
"join_error": "Failed to join project",
181+
"leave_error": "Failed to leave project",
175182
"card": {
176183
"backend": "Backend",
177184
"settings": "Settings"
@@ -181,6 +188,8 @@
181188
"project_name": "Project name",
182189
"owner": "Owner",
183190
"project_name_description": "Only latin characters, dashes, underscores, and digits",
191+
"is_public": "Make project public",
192+
"is_public_description": "Public projects can be accessed by any user without being a member",
184193
"backend": "Backend",
185194
"backend_config": "Backend config",
186195
"backend_config_description": "Specify the backend config in the YAML format. Click Info for examples.",
@@ -189,6 +198,10 @@
189198
"members_empty_message_title": "No members",
190199
"members_empty_message_text": "Select project's members",
191200
"update_members_success": "Members are updated",
201+
"update_visibility_success": "Project visibility updated successfully",
202+
"project_visibility": "Project Visibility",
203+
"project_visibility_description": "Control who can access this project",
204+
"make_project_public": "Make project public",
192205
"delete_project_confirm_title": "Delete project",
193206
"delete_project_confirm_message": "Are you sure you want to delete this project?",
194207
"delete_projects_confirm_title": "Delete projects",

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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, useUpdateProjectVisibilityMutation } 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,7 +54,9 @@ export const ProjectSettings: React.FC = () => {
5154

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

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

@@ -113,14 +118,39 @@ export const ProjectSettings: React.FC = () => {
113118

114119
const debouncedMembersHandler = useCallback(debounce(changeMembersHandler, 1000), []);
115120

121+
const changeVisibilityHandler = (is_public: boolean) => {
122+
updateProjectVisibility({
123+
project_name: paramProjectName,
124+
is_public,
125+
})
126+
.unwrap()
127+
.then(() => {
128+
pushNotification({
129+
type: 'success',
130+
content: t('projects.edit.update_visibility_success'),
131+
});
132+
})
133+
.catch((error) => {
134+
pushNotification({
135+
type: 'error',
136+
content: t('common.server_error', { error: error?.data?.detail?.msg }),
137+
});
138+
});
139+
};
140+
116141
const isDisabledButtons = useMemo<boolean>(() => {
117142
return isDeleting || !data || !isAvailableDeletingPermission(data);
118143
}, [data, isDeleting, isAvailableDeletingPermission]);
119144

120145
const deleteProjectHandler = () => {
121146
if (!data) return;
122147

123-
deleteProject(data).then(() => navigate(ROUTES.PROJECT.LIST));
148+
deleteProject(data)
149+
.then(() => navigate(ROUTES.PROJECT.LIST))
150+
.catch((error) => {
151+
// Error is already handled in useDeleteProject hook
152+
console.error('Delete project failed:', error);
153+
});
124154
};
125155

126156
if (isLoadingPage)
@@ -185,11 +215,29 @@ export const ProjectSettings: React.FC = () => {
185215

186216
<GatewaysTable gateways={gatewaysData} />
187217

218+
{isAvailableProjectManaging && (
219+
<Container header={<Header variant="h2">{t('projects.edit.project_visibility')}</Header>}>
220+
<SpaceBetween size="s">
221+
<Box variant="p" color="text-body-secondary">
222+
{t('projects.edit.project_visibility_description')}
223+
</Box>
224+
<Toggle
225+
checked={data.is_public}
226+
onChange={(detail) => changeVisibilityHandler(detail.detail.checked)}
227+
disabled={!isProjectManager(data)}
228+
>
229+
{t('projects.edit.make_project_public')}
230+
</Toggle>
231+
</SpaceBetween>
232+
</Container>
233+
)}
234+
188235
<ProjectMembers
189236
onChange={debouncedMembersHandler}
190237
members={data.members}
191238
readonly={!isProjectManager(data)}
192239
isAdmin={isProjectAdmin(data)}
240+
project={data}
193241
/>
194242

195243
<Container header={<Header variant="h2">{t('common.danger_zone')}</Header>}>

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22
import { useForm } from 'react-hook-form';
33
import { useTranslation } from 'react-i18next';
44

5-
import { Button, Container, FormInput, FormUI, Header, SpaceBetween } from 'components';
5+
import { Button, Container, FormCheckbox, FormInput, FormUI, Header, SpaceBetween } from 'components';
66

77
import { useNotifications } from 'hooks';
88
import { isResponseServerError, isResponseServerFormFieldError } from 'libs';
@@ -16,6 +16,7 @@ export const ProjectForm: React.FC<IProps> = ({ initialValues, onCancel, loading
1616

1717
const formMethods = useForm<IProject>({
1818
defaultValues: {
19+
is_public: false,
1920
...initialValues,
2021
},
2122
});
@@ -81,6 +82,14 @@ export const ProjectForm: React.FC<IProps> = ({ initialValues, onCancel, loading
8182
},
8283
}}
8384
/>
85+
86+
<FormCheckbox
87+
label={t('projects.edit.is_public')}
88+
description={t('projects.edit.is_public_description')}
89+
control={control}
90+
name="is_public"
91+
disabled={loading}
92+
/>
8493
</SpaceBetween>
8594
</Container>
8695
</SpaceBetween>

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

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useEffect, useMemo, useState } from 'react';
22
import { useFieldArray, useForm } from 'react-hook-form';
33
import { useTranslation } from 'react-i18next';
4+
import { useNavigate } from 'react-router-dom';
45

5-
import { Button, FormSelect, Header, Link, ListEmptyMessage, Pagination, Table } from 'components';
6+
import { Button, FormSelect, Header, Link, ListEmptyMessage, Pagination, SpaceBetween, Table } from 'components';
67

7-
import { useCollection } from 'hooks';
8+
import { useAppSelector, useCollection, useNotifications } from 'hooks';
9+
import { selectUserData } from 'App/slice';
810
import { ROUTES } from 'routes';
911
import { useGetUserListQuery } from 'services/user';
12+
import { useAddProjectMemberMutation, useRemoveProjectMemberMutation } from 'services/project';
1013

1114
import { UserAutosuggest } from './UsersAutosuggest';
1215

@@ -16,10 +19,15 @@ import { TRoleSelectOption } from 'pages/User/Form/types';
1619

1720
import styles from './styles.module.scss';
1821

19-
export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, readonly, isAdmin }) => {
22+
export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, readonly, isAdmin, project }) => {
2023
const { t } = useTranslation();
24+
const navigate = useNavigate();
25+
const [pushNotification] = useNotifications();
2126
const [selectedItems, setSelectedItems] = useState<TProjectMemberWithIndex[]>([]);
2227
const { data: usersData } = useGetUserListQuery();
28+
const userData = useAppSelector(selectUserData);
29+
const [addMember, { isLoading: isAdding }] = useAddProjectMemberMutation();
30+
const [removeMember, { isLoading: isRemoving }] = useRemoveProjectMemberMutation();
2331

2432
const { handleSubmit, control, getValues, setValue } = useForm<TFormValues>({
2533
defaultValues: { members: members ?? [] },
@@ -30,6 +38,67 @@ export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, r
3038
name: 'members',
3139
});
3240

41+
const currentUserRole = useMemo(() => {
42+
if (!userData?.username) return null;
43+
const member = members?.find(m => m.user.username === userData.username);
44+
return member?.project_role || null;
45+
}, [members, userData?.username]);
46+
47+
const isProjectOwner = useMemo(() => {
48+
return userData?.username === project?.owner.username;
49+
}, [userData?.username, project?.owner.username]);
50+
51+
const isMember = currentUserRole !== null;
52+
const isMemberActionLoading = isAdding || isRemoving;
53+
54+
const handleJoinProject = async () => {
55+
if (!userData?.username || !project) return;
56+
57+
try {
58+
await addMember({
59+
project_name: project.project_name,
60+
username: userData.username,
61+
project_role: 'user',
62+
}).unwrap();
63+
64+
pushNotification({
65+
type: 'success',
66+
content: t('projects.join_success'),
67+
});
68+
} catch (error) {
69+
console.error('Failed to join project:', error);
70+
pushNotification({
71+
type: 'error',
72+
content: t('projects.join_error'),
73+
});
74+
}
75+
};
76+
77+
const handleLeaveProject = async () => {
78+
if (!userData?.username || !project) return;
79+
80+
try {
81+
await removeMember({
82+
project_name: project.project_name,
83+
username: userData.username,
84+
}).unwrap();
85+
86+
pushNotification({
87+
type: 'success',
88+
content: t('projects.leave_success'),
89+
});
90+
91+
// Redirect to project list after successfully leaving
92+
navigate(ROUTES.PROJECT.LIST);
93+
} catch (error) {
94+
console.error('Failed to leave project:', error);
95+
pushNotification({
96+
type: 'error',
97+
content: t('projects.leave_error'),
98+
});
99+
}
100+
};
101+
33102
useEffect(() => {
34103
if (members) {
35104
setValue('members', members);
@@ -60,7 +129,7 @@ export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, r
60129
{ label: t('roles.user'), value: 'user' },
61130
];
62131

63-
const addMember = (username: string) => {
132+
const addMemberHandler = (username: string) => {
64133
const selectedUser = usersData?.find((u) => u.username === username);
65134

66135
if (selectedUser) {
@@ -90,6 +159,61 @@ export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, r
90159
onChangeHandler();
91160
};
92161

162+
const renderMemberActions = () => {
163+
const actions = [];
164+
165+
// Add management actions only if not readonly
166+
if (!readonly) {
167+
actions.push(
168+
<Button
169+
key="delete"
170+
formAction="none"
171+
onClick={deleteSelectedMembers}
172+
disabled={!selectedItems.length}
173+
>
174+
{t('common.delete')}
175+
</Button>
176+
);
177+
}
178+
179+
// Add join/leave button if user is authenticated (available even in readonly mode)
180+
if (userData?.username && project) {
181+
if (!isMember) {
182+
actions.unshift(
183+
<Button
184+
key="join"
185+
onClick={handleJoinProject}
186+
disabled={isMemberActionLoading}
187+
variant="primary"
188+
>
189+
{isMemberActionLoading ? t('common.loading') : t('projects.join')}
190+
</Button>
191+
);
192+
} else {
193+
// Prevent owners and admins from leaving their projects
194+
const canLeave = !isProjectOwner && currentUserRole !== 'admin';
195+
196+
actions.unshift(
197+
<Button
198+
key="leave"
199+
onClick={handleLeaveProject}
200+
disabled={isMemberActionLoading || !canLeave}
201+
variant="normal"
202+
>
203+
{!canLeave
204+
? t('projects.owner_cannot_leave')
205+
: isMemberActionLoading
206+
? t('common.loading')
207+
: t('projects.leave')
208+
}
209+
</Button>
210+
);
211+
}
212+
}
213+
214+
return actions.length > 0 ? <SpaceBetween size="xs" direction="horizontal">{actions}</SpaceBetween> : undefined;
215+
};
216+
93217
const COLUMN_DEFINITIONS = [
94218
{
95219
id: 'name',
@@ -153,13 +277,7 @@ export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, r
153277
<Header
154278
variant="h2"
155279
counter={`(${items?.length})`}
156-
actions={
157-
readonly ? undefined : (
158-
<Button formAction="none" onClick={deleteSelectedMembers} disabled={!selectedItems.length}>
159-
{t('common.delete')}
160-
</Button>
161-
)
162-
}
280+
actions={renderMemberActions()}
163281
>
164282
{t('projects.edit.members.section_title')}
165283
</Header>
@@ -168,7 +286,7 @@ export const ProjectMembers: React.FC<IProps> = ({ members, loading, onChange, r
168286
readonly ? undefined : (
169287
<UserAutosuggest
170288
disabled={loading}
171-
onSelect={({ detail }) => addMember(detail.value)}
289+
onSelect={({ detail }) => addMemberHandler(detail.value)}
172290
optionsFilter={(options) => options.filter((o) => !fields.find((f) => f.user.username === o.value))}
173291
/>
174292
)

frontend/src/pages/Project/Members/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export interface IProps {
44
onChange: (users: IProjectMember[]) => void;
55
readonly?: boolean;
66
isAdmin?: boolean;
7+
project?: IProject;
78
}
89

910
export type TProjectMemberWithIndex = IProjectMember & { index: number };

0 commit comments

Comments
 (0)