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/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/constants.ts b/src/authz-module/constants.ts index 62feb715..3256bc2c 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -2,3 +2,11 @@ export const ROUTES = { LIBRARIES_TEAM_PATH: '/libraries/:libraryId', LIBRARIES_USER_PATH: '/libraries/:libraryId/:username', }; + +export enum RoleOperationErrorStatus { + USER_NOT_FOUND = 'user_not_found', + USER_ALREADY_HAS_ROLE = 'user_already_has_role', + USER_DOES_NOT_HAVE_ROLE = 'user_does_not_have_role', + ROLE_ASSIGNMENT_ERROR = 'role_assignment_error', + ROLE_REMOVAL_ERROR = 'role_removal_error', +} diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index ea1e223b..86f5fb04 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -13,6 +13,16 @@ export type PermissionsByRole = { permissions: string[]; userCount: number; }; +export interface PutAssignTeamMembersRoleResponse { + completed: { user: string; status: string }[]; + errors: { userIdentifier: string; error: string }[]; +} + +export interface AssignTeamMembersRoleRequest { + users: string[]; + role: string; + scope: string; +} // TODO: replece api path once is created export const getTeamMembers = async (object: string): Promise => { @@ -20,6 +30,13 @@ export const getTeamMembers = async (object: string): Promise => { return camelCaseObject(data.results); }; +export const assignTeamMembersRole = async ( + data: AssignTeamMembersRoleRequest, +): Promise => { + const res = await getAuthenticatedHttpClient().put(getApiUrl('/api/authz/v1/roles/users/'), data); + return camelCaseObject(res.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.test.tsx b/src/authz-module/data/hooks.test.tsx index 79fafc43..ad22f54f 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, useAssignTeamMembersRole, +} 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('useAssignTeamMembersRole', () => { + 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(() => useAssignTeamMembersRole(), { + 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(() => useAssignTeamMembersRole(), { + 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/data/hooks.ts b/src/authz-module/data/hooks.ts index 658f548b..195b5149 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,7 +1,11 @@ -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 { + assignTeamMembersRole, + AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole, } from './api'; @@ -60,3 +64,23 @@ export const useLibrary = (libraryId: string) => useSuspenseQuery getLibrary(libraryId), retry: false, }); + +/** + * React Query hook to add new team members to a specific scope or manage the corresponding roles. + * It provides a mutation function to add users with specified roles to the team or assign new roles. + * + * @example + * const { mutate: assignTeamMembersRole } = useAssignTeamMembersRole(); + * assignTeamMembersRole({ data: { libraryId: 'lib:123', users: ['jdoe'], role: 'editor' } }); + */ +export const useAssignTeamMembersRole = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ data }: { + data: AssignTeamMembersRoleRequest + }) => assignTeamMembersRole(data), + onSettled: (_data, _error, { data: { scope } }) => { + queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembers(scope) }); + }, + }); +}; 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.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index ad3a8d96..bbbb7ff7 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -24,6 +24,11 @@ jest.mock('./components/TeamTable', () => ({ default: () =>
MockTeamTable
, })); +jest.mock('./components/AddNewTeamMemberModal', () => ({ + __esModule: true, + AddNewTeamMemberTrigger: () =>
MockAddNewTeamMemberTrigger
, +})); + describe('LibrariesTeamManager', () => { beforeEach(() => { initializeMockApp({ @@ -63,5 +68,8 @@ describe('LibrariesTeamManager', () => { // TeamTable is rendered expect(screen.getByTestId('team-table')).toBeInTheDocument(); + + // AddNewTeamMemberTrigger is rendered + expect(screen.getByTestId('add-team-member-trigger')).toBeInTheDocument(); }); }); diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index 48c13dcb..9595ce88 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -1,15 +1,18 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Tab, Tabs } from '@openedx/paragon'; import { useLibrary } from '@src/authz-module/data/hooks'; +import { useLocation } from 'react-router-dom'; import TeamTable from './components/TeamTable'; import AuthZLayout from '../components/AuthZLayout'; import { useLibraryAuthZ } from './context'; +import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal'; import messages from './messages'; const LibrariesTeamManager = () => { const intl = useIntl(); - const { libraryId } = useLibraryAuthZ(); + const { hash } = useLocation(); + const { libraryId, canManageTeam } = useLibraryAuthZ(); const { data: library } = useLibrary(libraryId); const rootBradecrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; const pageTitle = intl.formatMessage(messages['library.authz.manage.page.title']); @@ -21,11 +24,15 @@ const LibrariesTeamManager = () => { activeLabel={pageTitle} pageTitle={pageTitle} pageSubtitle={libraryId} - actions={[]} + actions={ + canManageTeam + ? [] + : [] + } > @@ -34,7 +41,7 @@ const LibrariesTeamManager = () => { Role tab. - + Permissions tab. diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.test.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.test.tsx new file mode 100644 index 00000000..e4ad08a8 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.test.tsx @@ -0,0 +1,244 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; +import AddNewTeamMemberModal from './AddNewTeamMemberModal'; + +// Mock the context module +jest.mock('@src/authz-module/libraries-manager/context', () => { + const actual = jest.requireActual('@src/authz-module/libraries-manager/context'); + return { + ...actual, + useLibraryAuthZ: jest.fn(), + }; +}); +const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useTeamRoles: jest.fn(), +})); + +const defaultProps = { + isOpen: true, + isLoading: false, + isError: false, + formValues: { + users: '', + role: '', + }, + close: jest.fn(), + onSave: jest.fn(), + handleChangeForm: jest.fn(), +}; + +const mockRoles = [ + { + role: 'instructor', + name: 'instructor', + description: 'Can create and edit content', + userCount: 3, + objects: [ + { + object: 'library', + description: 'Library permissions', + actions: ['view', 'edit', 'delete'], + }, + ], + }, + { + role: 'admin', + name: 'admin', + description: 'Full access to the library', + userCount: 1, + objects: [ + { + object: 'library', + description: 'Library permissions', + actions: ['view', 'edit', 'delete', 'manage'], + }, + ], + }, +]; + +describe('AddNewTeamMemberModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUseLibraryAuthZ.mockReturnValue({ + username: 'testuser', + libraryId: 'lib123', + roles: mockRoles, + canManageTeam: true, + }); + }); + + const renderModal = (props = {}) => { + const finalProps = { ...defaultProps, ...props }; + return renderWrapper( + , + ); + }; + + describe('Modal Rendering', () => { + it('renders the modal when isOpen is true', () => { + renderModal(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Add New Team Member')).toBeInTheDocument(); + }); + + it('does not render the modal when isOpen is false', () => { + renderModal({ isOpen: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('Form Elements', () => { + it('renders the users textarea with correct label', () => { + renderModal(); + + expect(screen.getByLabelText('Add users by username or email')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /add users by username or email/i })).toBeInTheDocument(); + }); + + it('renders the role select dropdown with correct label', () => { + renderModal(); + + expect(screen.getAllByLabelText('Roles')[0]).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /roles/i })).toBeInTheDocument(); + }); + + it('renders role options correctly', () => { + renderModal(); + + expect(screen.getByText('Select a role')).toBeInTheDocument(); + mockRoles.forEach((role) => { + expect(screen.getByText(role.role)).toBeInTheDocument(); + }); + }); + + it('displays form values correctly', () => { + renderModal({ + formValues: { + users: 'user1@example.com, user2@example.com', + role: 'instructor', + }, + }); + + expect(screen.getByDisplayValue('user1@example.com, user2@example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('instructor')).toBeInTheDocument(); + }); + }); + + describe('Form Interactions', () => { + it('calls handleChangeForm when users textarea changes', async () => { + const user = userEvent.setup(); + const handleChangeForm = jest.fn(); + renderModal({ handleChangeForm }); + + const usersTextarea = screen.getByRole('textbox', { name: /add users by username or email/i }); + await user.type(usersTextarea, 'test@example.com'); + + expect(handleChangeForm).toHaveBeenCalled(); + }); + + it('calls handleChangeForm when role select changes', async () => { + const user = userEvent.setup(); + const handleChangeForm = jest.fn(); + renderModal({ handleChangeForm }); + + const roleSelect = screen.getByRole('combobox', { name: /roles/i }); + await user.selectOptions(roleSelect, 'instructor'); + + expect(handleChangeForm).toHaveBeenCalled(); + }); + }); + + describe('Modal Actions', () => { + it('renders Cancel and Save buttons', () => { + renderModal(); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('calls close function when Cancel button is clicked', async () => { + const user = userEvent.setup(); + const close = jest.fn(); + renderModal({ close }); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('calls onSave function when Save button is clicked', async () => { + const user = userEvent.setup(); + const onSave = jest.fn(); + renderModal({ + onSave, + formValues: { + users: 'test@example.com', + role: 'instructor', + }, + }); + + await user.click(screen.getByRole('button', { name: /save/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it('disables Save button when users field is empty', () => { + renderModal({ + formValues: { + users: '', + role: 'instructor', + }, + }); + + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('disables Save button when role field is empty', () => { + renderModal({ + formValues: { + users: 'test@example.com', + role: '', + }, + }); + + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('enables Save button when both fields are filled', () => { + renderModal({ + formValues: { + users: 'test@example.com', + role: 'instructor', + }, + }); + + expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled(); + }); + }); + + describe('Loading State', () => { + it('disables Cancel button when loading', () => { + renderModal({ isLoading: true }); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); + }); + + it('disables Save button when loading', () => { + renderModal({ + isLoading: true, + formValues: { + users: 'test@example.com', + role: 'instructor', + }, + }); + + expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled(); + }); + }); +}); diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx new file mode 100644 index 00000000..e7d04348 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx @@ -0,0 +1,127 @@ +import { FC, useRef } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Form, Hyperlink, Icon, IconButton, ModalDialog, + ModalPopup, + Stack, + StatefulButton, + useToggle, +} from '@openedx/paragon'; +import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; +import { Info, SpinnerSimple } from '@openedx/paragon/icons'; +import messages from './messages'; + +interface AddNewTeamMemberModalProps { + isOpen: boolean; + isError: boolean; + isLoading: boolean; + formValues: { + users: string; + role: string; + }; + close: () => void; + onSave: () => void; + handleChangeForm: (e: React.ChangeEvent) => void; +} + +const AddNewTeamMemberModal: FC = ({ + isOpen, isError, isLoading, formValues, close, onSave, handleChangeForm, +}) => { + const intl = useIntl(); + const { roles } = useLibraryAuthZ(); + const [isOpenRolesPopUp, openRolesPopUp, closeRolesPopUp] = useToggle(false); + const targetRolesPopUpRef = useRef(null); + return ( + <> + +
+
    + {roles.map((role) =>
  • {role.name}: {role.description}
  • )} +
+ {intl.formatMessage(messages['libraries.authz.manage.tooltip.roles.extra.info'])} +
+
+ { } : 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)} + placeholder={intl.formatMessage(messages['libraries.authz.manage.add.member.users.placeholder'])} + style={{ color: isError && 'var(--pgn-color-form-feedback-invalid)' }} + /> + + + + + {intl.formatMessage(messages['libraries.authz.manage.add.member.roles.label'])} + + + handleChangeForm(e)}> + + {roles.map((role) => )} + + +
+
+ + + + + {intl.formatMessage(messages['libraries.authz.manage.cancel.button'])} + + , + }} + state={isLoading ? 'pending' : 'default'} + onClick={() => onSave()} + disabledStates={['pending']} + disabled={isLoading || !formValues.users || !formValues.role} + /> + + +
+ + ); +}; + +export default AddNewTeamMemberModal; diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx new file mode 100644 index 00000000..dc7603d0 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx @@ -0,0 +1,290 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { useAssignTeamMembersRole } 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', () => ({ + useAssignTeamMembersRole: jest.fn(), +})); + +jest.mock('./AddNewTeamMemberModal', () => { + /* eslint-disable react/prop-types */ + const MockModal = ({ + isOpen, close, onSave, isLoading, formValues, handleChangeForm, + }) => ( + isOpen ? ( +
+ + +