From 0bb49c1ed7a870ab0bece5f43d6889c36f53e984 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 29 Sep 2025 14:27:12 -0500 Subject: [PATCH 01/19] feat: add team roles management and update related hooks and types --- src/authz-module/data/api.ts | 24 +++++++++++++++++++++++- src/authz-module/data/hooks.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index ea1e223b..4e5c59b0 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -1,5 +1,5 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { LibraryMetadata, TeamMember } from '@src/types'; +import { LibraryMetadata, TeamMember, TeamRole } from '@src/types'; import { camelCaseObject } from '@edx/frontend-platform'; import { getApiUrl, getStudioApiUrl } from '@src/data/utils'; @@ -13,6 +13,16 @@ export type PermissionsByRole = { permissions: string[]; userCount: number; }; +export interface PutTeamMembersResponse { + completed: { user: string; status: string }[]; + errors: { user: string; error: string }[]; +} + +export interface AddTeamMembersRequest { + users: string[]; + role: string; + scope: string; +} // TODO: replece api path once is created export const getTeamMembers = async (object: string): Promise => { @@ -20,6 +30,18 @@ export const getTeamMembers = async (object: string): Promise => { return camelCaseObject(data.results); }; +export const addTeamMembers = async ( + data: AddTeamMembersRequest, +): Promise => { + const res = await getAuthenticatedHttpClient().put(getApiUrl('/api/authz/v1/roles/users'), data); + return camelCaseObject(res.data); +}; + +export const getTeamRoles = async (libraryId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/?scope=${libraryId}`)); + return data; +}; + // TODO: this should be replaced in the future with Console API export const getLibrary = async (libraryId: string): Promise => { const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`)); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 658f548b..bd9f5057 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,8 +1,10 @@ -import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { + useMutation, useQuery, useQueryClient, useSuspenseQuery, +} from '@tanstack/react-query'; import { appId } from '@src/constants'; import { LibraryMetadata, TeamMember } from '@src/types'; import { - getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole, + addTeamMembers, AddTeamMembersRequest, getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole, } from './api'; const authzQueryKeys = { @@ -60,3 +62,25 @@ export const useLibrary = (libraryId: string) => useSuspenseQuery getLibrary(libraryId), retry: false, }); + +/** + * React Query hook to add new team members to a specific library. + * It provides a mutation function to add users with specified roles to the library's team. + * + * @example + * ```tsx + * const { mutate: addTeamMember, isPending } = useAddTeamMember(); + * addTeamMember({ data: { libraryId: 'lib:123', users: ['jdoe'], role: 'editor' } }); + * ``` + */ +export const useAddTeamMember = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ data }: { + data: AddTeamMembersRequest + }) => addTeamMembers(data), + onSettled: (_data, _error, { data: { scope } }) => { + queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembers(scope) }); + }, + }); +}; From de032167ac6aafc0fa0a3fcc8b1c4e3999aefacc Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 29 Sep 2025 14:28:00 -0500 Subject: [PATCH 02/19] feat: implement add new team member functionality with modal and actions --- src/authz-module/components/AuthZTitle.tsx | 22 +++- src/authz-module/index.scss | 9 ++ .../LibrariesTeamManager.tsx | 5 +- .../components/AddNewTeamMemberModal.tsx | 91 ++++++++++++++++ .../components/AddNewTeamMemberTrigger.tsx | 103 ++++++++++++++++++ .../libraries-manager/components/messages.ts | 45 ++++++++ 6 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 src/authz-module/libraries-manager/components/AddNewTeamMemberModal.tsx create mode 100644 src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.tsx diff --git a/src/authz-module/components/AuthZTitle.tsx b/src/authz-module/components/AuthZTitle.tsx index f0319fca..19c6c1a5 100644 --- a/src/authz-module/components/AuthZTitle.tsx +++ b/src/authz-module/components/AuthZTitle.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ComponentType, isValidElement, ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { Breadcrumb, Col, Container, Row, Button, Badge, @@ -11,6 +11,7 @@ interface BreadcrumbLink { interface Action { label: string; + icon?: ComponentType; onClick: () => void; } @@ -19,7 +20,7 @@ export interface AuthZTitleProps { pageTitle: string; pageSubtitle: string | ReactNode; navLinks?: BreadcrumbLink[]; - actions?: Action[]; + actions?: (Action | ReactNode)[]; } const AuthZTitle = ({ @@ -41,7 +42,22 @@ const AuthZTitle = ({
{ - actions.map(({ label, onClick }) => ) + actions.map((action) => { + if (isValidElement(action)) { + return action; + } + + const { label, icon, onClick } = action as Action; + return ( + + ); + }) }
diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 17f9719a..ab98fc34 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -34,4 +34,13 @@ height: var(--pgn-size-icon-xs); } } +} + + +.toast-container { + // Ensure toast appears above modal + z-index: 1000; + // Move toast to the right + left: auto; + right: var(--pgn-spacing-toast-container-gutter-lg); } \ No newline at end of file diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index 48c13dcb..749167d5 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -6,6 +6,7 @@ import AuthZLayout from '../components/AuthZLayout'; import { useLibraryAuthZ } from './context'; import messages from './messages'; +import AddNewTeamMemberTrigger from './components/AddNewTeamMemberTrigger'; const LibrariesTeamManager = () => { const intl = useIntl(); @@ -21,7 +22,9 @@ const LibrariesTeamManager = () => { activeLabel={pageTitle} pageTitle={pageTitle} pageSubtitle={libraryId} - actions={[]} + actions={[ + , + ]} > void; + onSave: () => void; + handleChangeForm: (e: React.ChangeEvent) => void; +} + +const AddNewTeamMemberModal: FC = ({ + isOpen, isLoading, formValues, close, onSave, handleChangeForm, +}) => { + const intl = useIntl(); + const { roles } = useLibraryAuthZ(); + return ( + {} : close} + size="lg" + variant="dark" + hasCloseButton + isOverflowVisible={false} + zIndex={5} + > + + + {intl.formatMessage(messages['libraries.authz.manage.add.member.title'])} + + + + + +

+ {intl.formatMessage(messages['libraries.authz.manage.add.member.description'])} +

+ + + {intl.formatMessage(messages['libraries.authz.manage.add.member.users.label'])} + handleChangeForm(e)} + /> + + + + {intl.formatMessage(messages['libraries.authz.manage.add.member.roles.label'])} + handleChangeForm(e)}> + + {roles.map(({ role }) => )} + + +
+
+ + + + + {intl.formatMessage(messages['libraries.authz.manage.add.member.cancel.button'])} + + + + +
+ ); +}; + +export default AddNewTeamMemberModal; diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.tsx new file mode 100644 index 00000000..6bc4e760 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.tsx @@ -0,0 +1,103 @@ +import React, { FC, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Toast, useToggle } from '@openedx/paragon'; +import { Plus } from '@openedx/paragon/icons'; + +import { useAddTeamMember } from 'authz-module/data/hooks'; +import AddNewTeamMemberModal from './AddNewTeamMemberModal'; +import messages from './messages'; + +interface AddNewTeamMemberTriggerProps { + libraryId: string; +} + +const DEFAULT_FORM_VALUES = { + users: '', + role: '', +}; + +const AddNewTeamMemberTrigger: FC = ({ + libraryId, +}) => { + const intl = useIntl(); + const [isOpen, open, close] = useToggle(false); + const [additionMessage, setAdditionMessage] = useState(null); + const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES); + + const { mutate: addTeamMember, isPending: isAddingNewTeamMember } = useAddTeamMember(); + + const handleChangeForm = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormValues((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleAddTeamMember = () => { + const data = { + users: formValues.users.split(',').map(user => user.trim()), + role: formValues.role, + scope: libraryId, + }; + + addTeamMember({ data }, { + onSuccess: (successData) => { + if (successData.completed.length) { + setAdditionMessage( + intl.formatMessage( + messages['libraries.authz.manage.add.member.success'], + { count: successData.completed.length }, + ), + ); + } + + if (successData.errors.length) { + setAdditionMessage((prevMessage) => ( + `${prevMessage ? `${prevMessage} ` : ''}${intl.formatMessage( + messages['libraries.authz.manage.add.member.failure'], + { count: successData.errors.length }, + )}` + )); + } else { + close(); + setFormValues(DEFAULT_FORM_VALUES); + } + }, + }); + }; + + return ( + <> + + + {isOpen && ( + + )} + + {additionMessage && ( + setAdditionMessage(null)} + show={!!additionMessage} + > + {additionMessage} + + )} + + ); +}; + +export default AddNewTeamMemberTrigger; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index 709ad1ef..0c3e8adf 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -31,6 +31,51 @@ const messages = defineMessages({ defaultMessage: 'Edit', description: 'Edit action', }, + 'libraries.authz.manage.add.member.title': { + id: 'libraries.authz.manage.add.member.title', + defaultMessage: 'Add New Team Member', + description: 'Title for the add new team member modal', + }, + 'libraries.authz.manage.add.member.users.label': { + id: 'libraries.authz.manage.add.member.users.label', + defaultMessage: 'Add users by username or email', + description: 'Label for the users input field in the add new team member modal', + }, + 'libraries.authz.manage.add.member.roles.label': { + id: 'libraries.authz.manage.add.member.roles.label', + defaultMessage: 'Roles', + description: 'Label for the roles select field in the add new team member modal', + }, + 'libraries.authz.manage.add.member.cancel.button': { + id: 'libraries.authz.manage.add.member.cancel.button', + defaultMessage: 'Cancel', + description: 'Label for the cancel button in the add new team member modal', + }, + 'libraries.authz.manage.add.member.save.button': { + id: 'libraries.authz.manage.add.member.save.button', + defaultMessage: 'Save', + description: 'Label for the save button in the add new team member modal', + }, + 'libraries.authz.manage.add.member.saving.button': { + id: 'libraries.authz.manage.add.member.saving.button', + defaultMessage: 'Saving...', + description: 'Label for the save button in the add new team member modal when saving', + }, + 'libraries.authz.manage.add.member.description': { + id: 'libraries.authz.manage.add.member.description', + defaultMessage: 'Add new members to this library\'s team and assign them a role to define their permissions.', + description: 'Description for the add new team member modal', + }, + 'libraries.authz.manage.add.member.success': { + id: 'libraries.authz.manage.add.member.success', + defaultMessage: '{count, plural, one {# team member added successfully.} other {# team members added successfully.}}', + description: 'Success message when adding new team members', + }, + 'libraries.authz.manage.add.member.failure': { + id: 'libraries.authz.manage.add.member.failure', + defaultMessage: 'We couldn\'t find a user for {count, plural, one {# email address or username.} other {# email addresses or usernames.}} Please check the email and try again, or invite them to join your organization first.', + description: 'Error message when adding new team members', + }, }); export default messages; From 7387ed9687808602ab1dab7d2673fca84f2c36db Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 29 Sep 2025 14:28:40 -0500 Subject: [PATCH 03/19] test: add some missing tests --- .../components/AuthZTitle.test.tsx | 28 +++ src/authz-module/data/hooks.test.tsx | 74 +++++- .../AddNewTeamMemberTrigger.test.tsx | 238 ++++++++++++++++++ .../components/AddNewTeamMemberTrigger.tsx | 2 +- 4 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.test.tsx diff --git a/src/authz-module/components/AuthZTitle.test.tsx b/src/authz-module/components/AuthZTitle.test.tsx index 1045912a..3f801fa4 100644 --- a/src/authz-module/components/AuthZTitle.test.tsx +++ b/src/authz-module/components/AuthZTitle.test.tsx @@ -65,4 +65,32 @@ describe('AuthZTitle', () => { expect(onClick).toHaveBeenCalled(); }); }); + + it('renders action buttons with icons', () => { + const mockIcon = () => Icon; + const onClick = jest.fn(); + const actions = [ + { label: 'Save', icon: mockIcon, onClick }, + ]; + + render(); + + const button = screen.getByRole('button', { name: 'Icon Save' }); + expect(button).toBeInTheDocument(); + expect(screen.getByTestId('mock-icon')).toBeInTheDocument(); + }); + + it('renders ReactNode actions alongside button actions', () => { + const onClick = jest.fn(); + const customAction =
Custom Action
; + const actions = [ + { label: 'Save', onClick }, + customAction, + ]; + + render(); + + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect(screen.getByTestId('custom-action')).toBeInTheDocument(); + }); }); diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 79fafc43..6c42dec4 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -2,7 +2,9 @@ import { ReactNode } from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { useLibrary, usePermissionsByRole, useTeamMembers } from './hooks'; +import { + useLibrary, usePermissionsByRole, useTeamMembers, useAddTeamMember, +} from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(), @@ -155,4 +157,74 @@ describe('usePermissionsByRole', () => { expect(e).toEqual(new Error('Not found')); } }); + + describe('useAddTeamMember', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('successfully adds team members', async () => { + const mockResponse = { + completed: [ + { + user: 'jdoe', + status: 'role_added', + }, + { + user: 'alice@example.com', + status: 'already_has_role', + }, + ], + errors: [], + }; + + getAuthenticatedHttpClient.mockReturnValue({ + put: jest.fn().mockResolvedValue({ data: mockResponse }), + }); + + const { result } = renderHook(() => useAddTeamMember(), { + wrapper: createWrapper(), + }); + + const addTeamMemberData = { + scope: 'lib:123', + users: ['jdoe'], + role: 'author', + }; + + await act(async () => { + result.current.mutate({ data: addTeamMemberData }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockResponse); + }); + + it('handles error when adding team members fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + put: jest.fn().mockRejectedValue(new Error('Failed to add members')), + }); + + const { result } = renderHook(() => useAddTeamMember(), { + wrapper: createWrapper(), + }); + + const addTeamMemberData = { + scope: 'lib:123', + users: ['jdoe'], + role: 'author', + }; + + await act(async () => { + result.current.mutate({ data: addTeamMemberData }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.error).toEqual(new Error('Failed to add members')); + }); + }); }); diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.test.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.test.tsx new file mode 100644 index 00000000..4df75634 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberTrigger.test.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { useAddTeamMember } from '@src/authz-module/data/hooks'; +import AddNewTeamMemberTrigger from './AddNewTeamMemberTrigger'; + +const mockMutate = jest.fn(); + +// Mock the hooks module +jest.mock('@src/authz-module/data/hooks', () => ({ + useAddTeamMember: jest.fn(), +})); + +jest.mock('./AddNewTeamMemberModal', () => { + /* eslint-disable react/prop-types */ + const MockModal = ({ + isOpen, close, onSave, isLoading, formValues, handleChangeForm, + }) => ( + isOpen ? ( +
+ + +