Skip to content

Commit 52fbb7e

Browse files
bra-i-amdcoa
andauthored
feat: [FC-0099] add button & modal for adding new team members (#3)
* feat: add team roles management and update related hooks and types * feat: implement add new team member functionality with modal and actions * test: add some missing tests * test: add unit tests for AddNewTeamMemberModal and update context mocks * test: add toast close functionality and loading state handling in AddNewTeamMemberTrigger tests * fix: update LibrariesAuthZTeamView to include canManageTeam check for AddNewTeamMemberTrigger * fix: correct API endpoint paths and update authorization scope format * refactor: improve error handling & address PR feedback * refactor: group AddNewTeamMemberModal in 1 folder * fix: reset modal values to close action * refactor: replace useAddTeamMember with useAssignTeamMembersRole * feat: add tooltip * test: fix test after rebase * refactor: enhance user intruction with placeholder * style: remove unnecessary inline style * fix: remove the error style on change the textarea value * fix: add useState to display toast * fix: remove empty strings from the user input * fix: validate error users to apply style --------- Co-authored-by: Diana Olarte <[email protected]>
1 parent b507311 commit 52fbb7e

File tree

16 files changed

+1119
-9
lines changed

16 files changed

+1119
-9
lines changed

src/authz-module/components/AuthZTitle.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,32 @@ describe('AuthZTitle', () => {
6565
expect(onClick).toHaveBeenCalled();
6666
});
6767
});
68+
69+
it('renders action buttons with icons', () => {
70+
const mockIcon = () => <span data-testid="mock-icon">Icon</span>;
71+
const onClick = jest.fn();
72+
const actions = [
73+
{ label: 'Save', icon: mockIcon, onClick },
74+
];
75+
76+
render(<AuthZTitle {...defaultProps} actions={actions} />);
77+
78+
const button = screen.getByRole('button', { name: 'Icon Save' });
79+
expect(button).toBeInTheDocument();
80+
expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
81+
});
82+
83+
it('renders ReactNode actions alongside button actions', () => {
84+
const onClick = jest.fn();
85+
const customAction = <div data-testid="custom-action">Custom Action</div>;
86+
const actions = [
87+
{ label: 'Save', onClick },
88+
customAction,
89+
];
90+
91+
render(<AuthZTitle {...defaultProps} actions={actions} />);
92+
93+
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
94+
expect(screen.getByTestId('custom-action')).toBeInTheDocument();
95+
});
6896
});

src/authz-module/components/AuthZTitle.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode } from 'react';
1+
import { ComponentType, isValidElement, ReactNode } from 'react';
22
import { Link } from 'react-router-dom';
33
import {
44
Breadcrumb, Col, Container, Row, Button, Badge,
@@ -11,6 +11,7 @@ interface BreadcrumbLink {
1111

1212
interface Action {
1313
label: string;
14+
icon?: ComponentType;
1415
onClick: () => void;
1516
}
1617

@@ -19,7 +20,7 @@ export interface AuthZTitleProps {
1920
pageTitle: string;
2021
pageSubtitle: string | ReactNode;
2122
navLinks?: BreadcrumbLink[];
22-
actions?: Action[];
23+
actions?: (Action | ReactNode)[];
2324
}
2425

2526
const AuthZTitle = ({
@@ -41,7 +42,22 @@ const AuthZTitle = ({
4142
<Col xs={12} md={4}>
4243
<div className="d-flex justify-content-md-end">
4344
{
44-
actions.map(({ label, onClick }) => <Button key={`authz-header-action-${label}`} onClick={onClick}>{label}</Button>)
45+
actions.map((action) => {
46+
if (isValidElement(action)) {
47+
return action;
48+
}
49+
50+
const { label, icon, onClick } = action as Action;
51+
return (
52+
<Button
53+
key={`authz-header-action-${label}`}
54+
iconBefore={icon}
55+
onClick={onClick}
56+
>
57+
{label}
58+
</Button>
59+
);
60+
})
4561
}
4662
</div>
4763
</Col>

src/authz-module/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,11 @@ export const ROUTES = {
22
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
33
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
44
};
5+
6+
export enum RoleOperationErrorStatus {
7+
USER_NOT_FOUND = 'user_not_found',
8+
USER_ALREADY_HAS_ROLE = 'user_already_has_role',
9+
USER_DOES_NOT_HAVE_ROLE = 'user_does_not_have_role',
10+
ROLE_ASSIGNMENT_ERROR = 'role_assignment_error',
11+
ROLE_REMOVAL_ERROR = 'role_removal_error',
12+
}

src/authz-module/data/api.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,30 @@ export type PermissionsByRole = {
1313
permissions: string[];
1414
userCount: number;
1515
};
16+
export interface PutAssignTeamMembersRoleResponse {
17+
completed: { user: string; status: string }[];
18+
errors: { userIdentifier: string; error: string }[];
19+
}
20+
21+
export interface AssignTeamMembersRoleRequest {
22+
users: string[];
23+
role: string;
24+
scope: string;
25+
}
1626

1727
// TODO: replece api path once is created
1828
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
1929
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
2030
return camelCaseObject(data.results);
2131
};
2232

33+
export const assignTeamMembersRole = async (
34+
data: AssignTeamMembersRoleRequest,
35+
): Promise<PutAssignTeamMembersRoleResponse> => {
36+
const res = await getAuthenticatedHttpClient().put(getApiUrl('/api/authz/v1/roles/users/'), data);
37+
return camelCaseObject(res.data);
38+
};
39+
2340
// TODO: this should be replaced in the future with Console API
2441
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
2542
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));

src/authz-module/data/hooks.test.tsx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { ReactNode } from 'react';
22
import { act, renderHook, waitFor } from '@testing-library/react';
33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
5-
import { useLibrary, usePermissionsByRole, useTeamMembers } from './hooks';
5+
import {
6+
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole,
7+
} from './hooks';
68

79
jest.mock('@edx/frontend-platform/auth', () => ({
810
getAuthenticatedHttpClient: jest.fn(),
@@ -155,4 +157,74 @@ describe('usePermissionsByRole', () => {
155157
expect(e).toEqual(new Error('Not found'));
156158
}
157159
});
160+
161+
describe('useAssignTeamMembersRole', () => {
162+
beforeEach(() => {
163+
jest.clearAllMocks();
164+
});
165+
166+
it('successfully adds team members', async () => {
167+
const mockResponse = {
168+
completed: [
169+
{
170+
user: 'jdoe',
171+
status: 'role_added',
172+
},
173+
{
174+
175+
status: 'already_has_role',
176+
},
177+
],
178+
errors: [],
179+
};
180+
181+
getAuthenticatedHttpClient.mockReturnValue({
182+
put: jest.fn().mockResolvedValue({ data: mockResponse }),
183+
});
184+
185+
const { result } = renderHook(() => useAssignTeamMembersRole(), {
186+
wrapper: createWrapper(),
187+
});
188+
189+
const addTeamMemberData = {
190+
scope: 'lib:123',
191+
users: ['jdoe'],
192+
role: 'author',
193+
};
194+
195+
await act(async () => {
196+
result.current.mutate({ data: addTeamMemberData });
197+
});
198+
199+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
200+
201+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
202+
expect(result.current.data).toEqual(mockResponse);
203+
});
204+
205+
it('handles error when adding team members fails', async () => {
206+
getAuthenticatedHttpClient.mockReturnValue({
207+
put: jest.fn().mockRejectedValue(new Error('Failed to add members')),
208+
});
209+
210+
const { result } = renderHook(() => useAssignTeamMembersRole(), {
211+
wrapper: createWrapper(),
212+
});
213+
214+
const addTeamMemberData = {
215+
scope: 'lib:123',
216+
users: ['jdoe'],
217+
role: 'author',
218+
};
219+
220+
await act(async () => {
221+
result.current.mutate({ data: addTeamMemberData });
222+
});
223+
224+
await waitFor(() => expect(result.current.isError).toBe(true));
225+
226+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
227+
expect(result.current.error).toEqual(new Error('Failed to add members'));
228+
});
229+
});
158230
});

src/authz-module/data/hooks.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
1+
import {
2+
useMutation, useQuery, useQueryClient, useSuspenseQuery,
3+
} from '@tanstack/react-query';
24
import { appId } from '@src/constants';
35
import { LibraryMetadata, TeamMember } from '@src/types';
46
import {
7+
assignTeamMembersRole,
8+
AssignTeamMembersRoleRequest,
59
getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole,
610
} from './api';
711

@@ -60,3 +64,23 @@ export const useLibrary = (libraryId: string) => useSuspenseQuery<LibraryMetadat
6064
queryFn: () => getLibrary(libraryId),
6165
retry: false,
6266
});
67+
68+
/**
69+
* React Query hook to add new team members to a specific scope or manage the corresponding roles.
70+
* It provides a mutation function to add users with specified roles to the team or assign new roles.
71+
*
72+
* @example
73+
* const { mutate: assignTeamMembersRole } = useAssignTeamMembersRole();
74+
* assignTeamMembersRole({ data: { libraryId: 'lib:123', users: ['jdoe'], role: 'editor' } });
75+
*/
76+
export const useAssignTeamMembersRole = () => {
77+
const queryClient = useQueryClient();
78+
return useMutation({
79+
mutationFn: async ({ data }: {
80+
data: AssignTeamMembersRoleRequest
81+
}) => assignTeamMembersRole(data),
82+
onSettled: (_data, _error, { data: { scope } }) => {
83+
queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembers(scope) });
84+
},
85+
});
86+
};

src/authz-module/index.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,13 @@
3434
height: var(--pgn-size-icon-xs);
3535
}
3636
}
37+
}
38+
39+
40+
.toast-container {
41+
// Ensure toast appears above modal
42+
z-index: 1000;
43+
// Move toast to the right
44+
left: auto;
45+
right: var(--pgn-spacing-toast-container-gutter-lg);
3746
}

src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ jest.mock('./components/TeamTable', () => ({
2424
default: () => <div data-testid="team-table">MockTeamTable</div>,
2525
}));
2626

27+
jest.mock('./components/AddNewTeamMemberModal', () => ({
28+
__esModule: true,
29+
AddNewTeamMemberTrigger: () => <div data-testid="add-team-member-trigger">MockAddNewTeamMemberTrigger</div>,
30+
}));
31+
2732
describe('LibrariesTeamManager', () => {
2833
beforeEach(() => {
2934
initializeMockApp({
@@ -63,5 +68,8 @@ describe('LibrariesTeamManager', () => {
6368

6469
// TeamTable is rendered
6570
expect(screen.getByTestId('team-table')).toBeInTheDocument();
71+
72+
// AddNewTeamMemberTrigger is rendered
73+
expect(screen.getByTestId('add-team-member-trigger')).toBeInTheDocument();
6674
});
6775
});

src/authz-module/libraries-manager/LibrariesTeamManager.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { useIntl } from '@edx/frontend-platform/i18n';
22
import { Tab, Tabs } from '@openedx/paragon';
33
import { useLibrary } from '@src/authz-module/data/hooks';
4+
import { useLocation } from 'react-router-dom';
45
import TeamTable from './components/TeamTable';
56
import AuthZLayout from '../components/AuthZLayout';
67
import { useLibraryAuthZ } from './context';
8+
import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal';
79

810
import messages from './messages';
911

1012
const LibrariesTeamManager = () => {
1113
const intl = useIntl();
12-
const { libraryId } = useLibraryAuthZ();
14+
const { hash } = useLocation();
15+
const { libraryId, canManageTeam } = useLibraryAuthZ();
1316
const { data: library } = useLibrary(libraryId);
1417
const rootBradecrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
1518
const pageTitle = intl.formatMessage(messages['library.authz.manage.page.title']);
@@ -21,11 +24,15 @@ const LibrariesTeamManager = () => {
2124
activeLabel={pageTitle}
2225
pageTitle={pageTitle}
2326
pageSubtitle={libraryId}
24-
actions={[]}
27+
actions={
28+
canManageTeam
29+
? [<AddNewTeamMemberTrigger libraryId={libraryId} />]
30+
: []
31+
}
2532
>
2633
<Tabs
2734
variant="tabs"
28-
defaultActiveKey="team"
35+
defaultActiveKey={hash ? 'permissions' : 'team'}
2936
className="bg-light-100 px-5"
3037
>
3138
<Tab eventKey="team" title={intl.formatMessage(messages['library.authz.tabs.team'])} className="p-5">
@@ -34,7 +41,7 @@ const LibrariesTeamManager = () => {
3441
<Tab eventKey="roles" title={intl.formatMessage(messages['library.authz.tabs.roles'])}>
3542
Role tab.
3643
</Tab>
37-
<Tab eventKey="permissions" title={intl.formatMessage(messages['library.authz.tabs.permissions'])}>
44+
<Tab id="libraries-permissions-tab" eventKey="permissions" title={intl.formatMessage(messages['library.authz.tabs.permissions'])}>
3845
Permissions tab.
3946
</Tab>
4047
</Tabs>

0 commit comments

Comments
 (0)