From 658a57725aa4a053e97f7a8b67f869023b03fed2 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 09:05:06 +1100 Subject: [PATCH 1/7] refactor: support components as action & connect API to add roles to users * fix: the value of LIBRARY_AUTHZ_SCOPE was misspelled --- src/authz-module/data/api.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 86f5fb04..337becdc 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -24,6 +24,17 @@ export interface AssignTeamMembersRoleRequest { scope: string; } +export interface PutAssignTeamMembersRoleResponse { + completed: { user: string; status: string }[]; + errors: { user: 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 => { const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); From a92f4780cc8b43c0f10a9a5134dbd9357dfa79c0 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Tue, 7 Oct 2025 16:28:21 -0500 Subject: [PATCH 2/7] feat: add role assignment functionality with modal and trigger components --- .../LibrariesUserManager.tsx | 9 ++- .../components/AssignNewRoleModal.tsx | 70 +++++++++++++++++++ .../components/AssignNewRoleTrigger.tsx | 70 +++++++++++++++++++ .../libraries-manager/components/messages.ts | 20 ++++++ 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/authz-module/libraries-manager/components/AssignNewRoleModal.tsx create mode 100644 src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 9952ae1c..2cf7eb2b 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -10,6 +10,7 @@ import { useLibrary, useTeamMembers } from '../data/hooks'; import { buildPermissionsByRoleMatrix } from './utils'; import messages from './messages'; +import AddNewMemberRoleTrigger from './components/AssignNewRoleTrigger'; const LibrariesUserManager = () => { const intl = useIntl(); @@ -42,7 +43,13 @@ const LibrariesUserManager = () => { activeLabel={user?.username || ''} pageTitle={user?.username || ''} pageSubtitle={

{user?.email}

} - actions={[]} + actions={user + ? [ role.role)} + />] + : []} > {isLoading ? : null} diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal.tsx new file mode 100644 index 00000000..8aad0d39 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal.tsx @@ -0,0 +1,70 @@ +import { FC } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, Form, ModalDialog, +} from '@openedx/paragon'; +import { Role } from 'types'; +import messages from './messages'; + +interface AssignNewRoleModalProps { + isOpen: boolean; + isLoading: boolean; + roleOptions: Role[]; + selectedRole: string; + close: () => void; + onSave: () => void; + handleChangeSelectedRole: (e: React.ChangeEvent) => void; +} + +const AssignNewRoleModal: FC = ({ + isOpen, isLoading, selectedRole, roleOptions, close, onSave, handleChangeSelectedRole, +}) => { + const intl = useIntl(); + return ( + {} : close} + size="lg" + variant="dark" + hasCloseButton + isOverflowVisible={false} + zIndex={5} + > + + + {intl.formatMessage(messages['libraries.authz.manage.assign.new.role.title'])} + + + + + + {intl.formatMessage(messages['library.authz.team.table.roles'])} + + + {roleOptions.map((role) => )} + + + + + + + + {intl.formatMessage(messages['libraries.authz.manage.cancel.button'])} + + + + + + ); +}; + +export default AssignNewRoleModal; diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx new file mode 100644 index 00000000..ee2d379b --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx @@ -0,0 +1,70 @@ +import React, { FC, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, useToggle } from '@openedx/paragon'; +import { Plus } from '@openedx/paragon/icons'; + +import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; +import { useAssignTeamMembersRole } from 'authz-module/data/hooks'; +import messages from './messages'; +import AssignNewRoleModal from './AssignNewRoleModal'; + +interface AssignNewRoleTriggerProps { + username: string; + libraryId: string; + currentUserRoles: string[]; +} + +const AssignNewRoleTrigger: FC = ({ + username, + libraryId, + currentUserRoles, +}) => { + const intl = useIntl(); + const [isOpen, open, close] = useToggle(false); + const { roles } = useLibraryAuthZ(); + + const [newRole, setNewRole] = useState(''); + + const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole(); + + const handleAddTeamMember = () => { + const data = { + users: [username], + role: newRole, + scope: libraryId, + }; + + assignTeamMembersRole({ data }, { + onSuccess: () => { + close(); + setNewRole(''); + }, + }); + }; + + return ( + <> + + + {isOpen && ( + !currentUserRoles.includes(role.role))} + selectedRole={newRole} + handleChangeSelectedRole={(e) => setNewRole(e.target.value)} + /> + )} + + ); +}; + +export default AssignNewRoleTrigger; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index 709ad1ef..d44c6d17 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -31,6 +31,26 @@ const messages = defineMessages({ defaultMessage: 'Edit', description: 'Edit action', }, + 'libraries.authz.manage.assign.new.role.title': { + id: 'libraries.authz.manage.assign.new.role.title', + defaultMessage: 'Add New Role', + description: 'Libraries AuthZ assign a new role to a user button title', + }, + 'libraries.authz.manage.cancel.button': { + id: 'libraries.authz.manage.cancel.button', + defaultMessage: 'Cancel', + description: 'Libraries AuthZ cancel button title', + }, + 'libraries.authz.manage.saving.button': { + id: 'libraries.authz.manage.saving.button', + defaultMessage: 'Saving...', + description: 'Libraries AuthZ saving button title', + }, + 'libraries.authz.manage.save.button': { + id: 'libraries.authz.manage.save.button', + defaultMessage: 'Save', + description: 'Libraries AuthZ save button title', + }, }); export default messages; From 6cf1446569d2c67e3006f3cff5efb38105a181b7 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Tue, 7 Oct 2025 16:35:13 -0500 Subject: [PATCH 3/7] feat: add success toast message for role assignment and update user management logic --- .../libraries-manager/LibrariesUserManager.tsx | 4 ++-- .../components/AssignNewRoleTrigger.tsx | 17 ++++++++++++++++- .../libraries-manager/components/messages.ts | 5 +++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 2cf7eb2b..1c2942cf 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -16,7 +16,7 @@ const LibrariesUserManager = () => { const intl = useIntl(); const { username } = useParams(); const { - libraryId, permissions, roles, resources, + libraryId, permissions, roles, resources, canManageTeam, } = useLibraryAuthZ(); const { data: library } = useLibrary(libraryId); const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; @@ -43,7 +43,7 @@ const LibrariesUserManager = () => { activeLabel={user?.username || ''} pageTitle={user?.username || ''} pageSubtitle={

{user?.email}

} - actions={user + actions={user && canManageTeam ? [ = ({ }) => { const intl = useIntl(); const [isOpen, open, close] = useToggle(false); + const [toastMessage, setToastMessage] = useState(null); const { roles } = useLibraryAuthZ(); const [newRole, setNewRole] = useState(''); @@ -36,6 +37,11 @@ const AssignNewRoleTrigger: FC = ({ assignTeamMembersRole({ data }, { onSuccess: () => { + setToastMessage( + intl.formatMessage( + messages['libraries.authz.manage.assign.role.success'], + ), + ); close(); setNewRole(''); }, @@ -63,6 +69,15 @@ const AssignNewRoleTrigger: FC = ({ handleChangeSelectedRole={(e) => setNewRole(e.target.value)} /> )} + + {toastMessage && ( + setToastMessage(null)} + show={!!toastMessage} + > + {toastMessage} + + )} ); }; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index d44c6d17..d36537e7 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -51,6 +51,11 @@ const messages = defineMessages({ defaultMessage: 'Save', description: 'Libraries AuthZ save button title', }, + 'libraries.authz.manage.assign.role.success': { + id: 'libraries.authz.manage.assign.role.success', + defaultMessage: 'Role added successfully.', + description: 'Libraries AuthZ assign role success message', + }, }); export default messages; From 0354fa2cd472fa0998194e8b56c2ddde5a6d1496 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 09:05:34 +1100 Subject: [PATCH 4/7] refactor: update handleAddRole to display all roles --- .../components/AssignNewRoleTrigger.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx index 095e7dcb..7279ce70 100644 --- a/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx +++ b/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx @@ -4,7 +4,7 @@ import { Button, Toast, useToggle } from '@openedx/paragon'; import { Plus } from '@openedx/paragon/icons'; import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; -import { useAssignTeamMembersRole } from 'authz-module/data/hooks'; +import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks'; import messages from './messages'; import AssignNewRoleModal from './AssignNewRoleModal'; @@ -28,13 +28,19 @@ const AssignNewRoleTrigger: FC = ({ const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole(); - const handleAddTeamMember = () => { + const handleAddRole = () => { const data = { users: [username], role: newRole, scope: libraryId, }; + if (currentUserRoles.includes(newRole)) { + close(); + setNewRole(''); + return; + } + assignTeamMembersRole({ data }, { onSuccess: () => { setToastMessage( @@ -62,11 +68,12 @@ const AssignNewRoleTrigger: FC = ({ !currentUserRoles.includes(role.role))} + roleOptions={roles} selectedRole={newRole} handleChangeSelectedRole={(e) => setNewRole(e.target.value)} + /> )} From a6c81765f656de60f8f0a7e4ed084f7cf3302207 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 14 Oct 2025 09:36:13 +1100 Subject: [PATCH 5/7] refactor: group under AssignNewRoleModal --- src/authz-module/libraries-manager/LibrariesUserManager.tsx | 4 ++-- .../{ => AssignNewRoleModal}/AssignNewRoleModal.tsx | 2 +- .../{ => AssignNewRoleModal}/AssignNewRoleTrigger.tsx | 2 +- .../libraries-manager/components/AssignNewRoleModal/index.ts | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) rename src/authz-module/libraries-manager/components/{ => AssignNewRoleModal}/AssignNewRoleModal.tsx (98%) rename src/authz-module/libraries-manager/components/{ => AssignNewRoleModal}/AssignNewRoleTrigger.tsx (98%) create mode 100644 src/authz-module/libraries-manager/components/AssignNewRoleModal/index.ts diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 1c2942cf..6883e818 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -6,11 +6,11 @@ import { ROUTES } from '@src/authz-module/constants'; import AuthZLayout from '../components/AuthZLayout'; import { useLibraryAuthZ } from './context'; import RoleCard from '../components/RoleCard'; +import { AssignNewRoleTrigger } from './components/AssignNewRoleModal'; import { useLibrary, useTeamMembers } from '../data/hooks'; import { buildPermissionsByRoleMatrix } from './utils'; import messages from './messages'; -import AddNewMemberRoleTrigger from './components/AssignNewRoleTrigger'; const LibrariesUserManager = () => { const intl = useIntl(); @@ -44,7 +44,7 @@ const LibrariesUserManager = () => { pageTitle={user?.username || ''} pageSubtitle={

{user?.email}

} actions={user && canManageTeam - ? [ role.role)} diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx similarity index 98% rename from src/authz-module/libraries-manager/components/AssignNewRoleModal.tsx rename to src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx index 8aad0d39..0fca938f 100644 --- a/src/authz-module/libraries-manager/components/AssignNewRoleModal.tsx +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx @@ -4,7 +4,7 @@ import { ActionRow, Button, Form, ModalDialog, } from '@openedx/paragon'; import { Role } from 'types'; -import messages from './messages'; +import messages from '../messages'; interface AssignNewRoleModalProps { isOpen: boolean; diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx similarity index 98% rename from src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx rename to src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx index 7279ce70..a0cc5085 100644 --- a/src/authz-module/libraries-manager/components/AssignNewRoleTrigger.tsx +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx @@ -5,7 +5,7 @@ import { Plus } from '@openedx/paragon/icons'; import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks'; -import messages from './messages'; +import messages from '../messages'; import AssignNewRoleModal from './AssignNewRoleModal'; interface AssignNewRoleTriggerProps { diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/index.ts b/src/authz-module/libraries-manager/components/AssignNewRoleModal/index.ts new file mode 100644 index 00000000..2f8d6091 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/index.ts @@ -0,0 +1,4 @@ +import AssignNewRoleModal from './AssignNewRoleModal'; +import AssignNewRoleTrigger from './AssignNewRoleTrigger'; + +export { AssignNewRoleModal, AssignNewRoleTrigger }; From dc9e4864c194f5adb5f39dac1d3313930e60fdfc Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Wed, 15 Oct 2025 17:39:51 -0500 Subject: [PATCH 6/7] test: add unit tests for AssignNewRoleModal and AssignNewRoleTrigger components --- .../AssignNewRoleModal.test.tsx | 219 +++++++++++++++ .../AssignNewRoleTrigger.test.tsx | 265 ++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.test.tsx create mode 100644 src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.test.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.test.tsx new file mode 100644 index 00000000..e8780570 --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.test.tsx @@ -0,0 +1,219 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { Role } from 'types'; +import AssignNewRoleModal from './AssignNewRoleModal'; + +describe('AssignNewRoleModal', () => { + const defaultProps = { + isOpen: true, + isLoading: false, + roleOptions: [ + { + role: 'instructor', + name: 'Instructor', + description: 'Can create and edit content', + userCount: 5, + permissions: ['view', 'edit'], + }, + { + role: 'admin', + name: 'Administrator', + description: 'Full access to the library', + userCount: 2, + permissions: ['view', 'edit', 'delete', 'manage'], + }, + { + role: 'viewer', + name: 'Viewer', + description: 'Can only view content', + userCount: 10, + permissions: ['view'], + }, + ] as Role[], + selectedRole: '', + close: jest.fn(), + onSave: jest.fn(), + handleChangeSelectedRole: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderComponent = (props = {}) => { + const finalProps = { ...defaultProps, ...props }; + return renderWrapper(); + }; + + describe('Modal Visibility', () => { + it('renders modal when isOpen is true', () => { + renderComponent({ isOpen: true }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Add New Role')).toBeInTheDocument(); + }); + + it('does not render modal when isOpen is false', () => { + renderComponent({ isOpen: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('Add New Role')).not.toBeInTheDocument(); + }); + }); + + describe('Modal Structure', () => { + it('renders modal header with correct title', () => { + renderComponent({ isOpen: true }); + + expect(screen.getByText('Add New Role')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('renders close button in header', () => { + renderComponent(); + + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + }); + + describe('Role Selection Form', () => { + it('renders role selection form with correct label', () => { + renderComponent(); + + expect(screen.getByText('Roles')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders default option', () => { + renderComponent(); + + expect(screen.getByText('Select a role')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Select a role' })).toBeDisabled(); + }); + + it('renders all role options', () => { + renderComponent(); + + defaultProps.roleOptions.forEach((role) => { + expect(screen.getByRole('option', { name: role.name })).toBeInTheDocument(); + }); + }); + + it('displays selected role correctly', () => { + renderComponent({ selectedRole: 'instructor' }); + + const selectElement = screen.getByRole('combobox'); + expect(selectElement).toHaveValue('instructor'); + }); + + it('calls handleChangeSelectedRole when role selection changes', async () => { + const user = userEvent.setup(); + renderComponent(); + + const selectElement = screen.getByRole('combobox'); + await user.selectOptions(selectElement, 'admin'); + + expect(defaultProps.handleChangeSelectedRole).toHaveBeenCalled(); + }); + }); + + describe('Action Buttons', () => { + it('renders Cancel button with correct text', () => { + renderComponent(); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('renders Save button with correct text when not loading', () => { + renderComponent({ isLoading: false }); + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('renders Save button with loading text when loading', () => { + renderComponent({ isLoading: true }); + + expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument(); + }); + + it('calls close when Cancel button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + expect(defaultProps.close).toHaveBeenCalledTimes(1); + }); + + it('calls onSave when Save button is clicked', async () => { + const user = userEvent.setup(); + renderComponent({ selectedRole: 'instructor' }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + await user.click(saveButton); + + expect(defaultProps.onSave).toHaveBeenCalledTimes(1); + }); + }); + + describe('Button States', () => { + it('disables Save button when no role is selected', () => { + renderComponent({ selectedRole: '' }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeDisabled(); + }); + + it('enables Save button when role is selected and not loading', () => { + renderComponent({ selectedRole: 'instructor', isLoading: false }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).not.toBeDisabled(); + }); + + it('disables Save button when loading', () => { + renderComponent({ selectedRole: 'instructor', isLoading: true }); + + const saveButton = screen.getByRole('button', { name: /saving/i }); + expect(saveButton).toBeDisabled(); + }); + + it('disables Cancel button when loading', () => { + renderComponent({ isLoading: true }); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + expect(cancelButton).toBeDisabled(); + }); + + it('enables Cancel button when not loading', () => { + renderComponent({ isLoading: false }); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + expect(cancelButton).not.toBeDisabled(); + }); + }); + + describe('Modal Close Behavior', () => { + it('does not call close when modal header close is clicked during loading', async () => { + const user = userEvent.setup(); + renderComponent({ isLoading: true }); + + const headerCloseButton = screen.getByRole('button', { name: /close/i }); + await user.click(headerCloseButton); + + expect(defaultProps.close).not.toHaveBeenCalled(); + }); + + it('calls close when modal header close is clicked and not loading', async () => { + const user = userEvent.setup(); + renderComponent({ isLoading: false }); + + const headerCloseButton = screen.getByRole('button', { name: /close/i }); + await user.click(headerCloseButton); + + expect(defaultProps.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx new file mode 100644 index 00000000..f39ee35d --- /dev/null +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx @@ -0,0 +1,265 @@ +import { screen, waitFor } 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 { useAssignTeamMembersRole } from '@src/authz-module/data/hooks'; +import AssignNewRoleTrigger from './AssignNewRoleTrigger'; + +jest.mock('@src/authz-module/libraries-manager/context', () => ({ + useLibraryAuthZ: jest.fn(), +})); + +jest.mock('@src/authz-module/data/hooks', () => ({ + useAssignTeamMembersRole: jest.fn(), +})); + +jest.mock('./AssignNewRoleModal', () => { + const MockAssignNewRoleModal = ({ + isOpen, + close, + onSave, + isLoading, + roleOptions, + selectedRole, + handleChangeSelectedRole, + }: any) => (isOpen ? ( +
+

Add New Role

+ + + +
+ ) : null); + MockAssignNewRoleModal.displayName = 'AssignNewRoleModal'; + return MockAssignNewRoleModal; +}); + +const mockUseLibraryAuthZ = useLibraryAuthZ as jest.Mock; +const mockUseAssignTeamMembersRole = useAssignTeamMembersRole as jest.Mock; + +describe('AssignNewRoleTrigger', () => { + const defaultProps = { + username: 'testuser', + libraryId: 'lib:test-library', + currentUserRoles: ['instructor'], + }; + + const mockRoles = [ + { + role: 'instructor', + name: 'Instructor', + description: 'Can create and edit content', + }, + { + role: 'admin', + name: 'Administrator', + description: 'Full access to the library', + }, + { + role: 'viewer', + name: 'Viewer', + description: 'Can only view content', + }, + ]; + + const mockMutate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseLibraryAuthZ.mockReturnValue({ + roles: mockRoles, + }); + + mockUseAssignTeamMembersRole.mockReturnValue({ + mutate: mockMutate, + isPending: false, + }); + }); + + const renderComponent = (props = {}) => { + const finalProps = { ...defaultProps, ...props }; + return renderWrapper(); + }; + + describe('Initial Render', () => { + it('renders the trigger button with correct text', () => { + renderComponent(); + + expect(screen.getByRole('button', { name: /add new role/i })).toBeInTheDocument(); + }); + + it('does not show modal initially', () => { + renderComponent(); + + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + + it('does not show toast initially', () => { + renderComponent(); + + expect(screen.queryByText(/role added successfully/i)).not.toBeInTheDocument(); + }); + }); + + describe('Modal Interaction', () => { + it('opens modal when trigger button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const triggerButton = screen.getByRole('button', { name: /add new role/i }); + await user.click(triggerButton); + + expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /add new role/i })).toBeInTheDocument(); + }); + + it('closes modal when cancel button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + // Open modal + await user.click(screen.getByRole('button', { name: /add new role/i })); + expect(screen.getByTestId('assign-new-role-modal')).toBeInTheDocument(); + + // Close modal + await user.click(screen.getByTestId('cancel-button')); + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Role Selection and Assignment', () => { + it('updates selected role when role select changes', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, 'admin'); + + expect(roleSelect).toHaveValue('admin'); + }); + + it('calls assignTeamMembersRole with correct data when save is clicked', async () => { + const choosenRole = mockRoles[1].role; // 'admin' + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + // Select a role + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, choosenRole); + + // Click save + await user.click(screen.getByTestId('save-button')); + + expect(mockMutate).toHaveBeenCalledWith( + { + data: { + users: [defaultProps.username], + role: choosenRole, + scope: defaultProps.libraryId, + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ); + }); + + it('does not call assignTeamMembersRole if user already has the selected role', async () => { + const choosenRole = mockRoles[1].role; // 'admin' + const user = userEvent.setup(); + renderComponent({ currentUserRoles: ['instructor', choosenRole] }); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + // Select a role that user already has + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, choosenRole); + + await user.click(screen.getByTestId('save-button')); + + // Should not call assignTeamMembersRole + expect(mockMutate).not.toHaveBeenCalled(); + // Modal should be closed + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + }); + + describe('Loading State', () => { + it('shows loading state in modal when assignment is pending', async () => { + mockUseAssignTeamMembersRole.mockReturnValue({ + mutate: mockMutate, + isPending: true, + }); + + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + expect(screen.getByTestId('save-button')).toBeDisabled(); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + }); + + describe('Success Handling', () => { + it('shows success toast after successful role assignment', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, 'admin'); + + await user.click(screen.getByTestId('save-button')); + + // Simulate successful API call + const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess; + onSuccessCallback(); + + await waitFor(() => { + expect(screen.getByText(/role added successfully/i)).toBeInTheDocument(); + }); + }); + + it('closes modal and resets role after successful assignment', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /add new role/i })); + + const roleSelect = screen.getByTestId('role-select'); + await user.selectOptions(roleSelect, 'admin'); + + await user.click(screen.getByTestId('save-button')); + + // Simulate successful API call + const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess; + onSuccessCallback(); + + await waitFor(() => { + expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument(); + }); + + // Open modal again to check if role is reset + await user.click(screen.getByRole('button', { name: /add new role/i })); + expect(screen.getByTestId('role-select')).toHaveValue(''); + }); + }); +}); From b828791ba39b120655066b19531a07cdf9d6af2e Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 22 Oct 2025 09:38:27 +1100 Subject: [PATCH 7/7] fix: remove duplicated exports after rebase --- src/authz-module/data/api.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 337becdc..d22b2140 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -24,18 +24,6 @@ export interface AssignTeamMembersRoleRequest { scope: string; } -export interface PutAssignTeamMembersRoleResponse { - completed: { user: string; status: string }[]; - errors: { user: 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 => { const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); return camelCaseObject(data.results);