Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0bb49c1
feat: add team roles management and update related hooks and types
bra-i-am Sep 29, 2025
de03216
feat: implement add new team member functionality with modal and actions
bra-i-am Sep 29, 2025
7387ed9
test: add some missing tests
bra-i-am Sep 29, 2025
b93512a
test: add unit tests for AddNewTeamMemberModal and update context mocks
bra-i-am Sep 29, 2025
7d298d1
test: add toast close functionality and loading state handling in Add…
bra-i-am Sep 29, 2025
fbf2680
fix: update LibrariesAuthZTeamView to include canManageTeam check for…
bra-i-am Oct 2, 2025
1e0a652
fix: correct API endpoint paths and update authorization scope format
bra-i-am Oct 8, 2025
9b85aee
refactor: improve error handling & address PR feedback
bra-i-am Oct 8, 2025
3d0cf35
refactor: group AddNewTeamMemberModal in 1 folder
dcoa Oct 13, 2025
b10b614
fix: reset modal values to close action
dcoa Oct 13, 2025
58dc7bd
refactor: replace useAddTeamMember with useAssignTeamMembersRole
dcoa Oct 13, 2025
6afc0da
feat: add tooltip
dcoa Oct 15, 2025
0b9c7b5
test: fix test after rebase
dcoa Oct 15, 2025
57ab33c
refactor: enhance user intruction with placeholder
dcoa Oct 20, 2025
398c747
style: remove unnecessary inline style
dcoa Oct 20, 2025
fb5e1b8
fix: remove the error style on change the textarea value
dcoa Oct 20, 2025
92e8467
fix: add useState to display toast
dcoa Oct 20, 2025
b661b81
fix: remove empty strings from the user input
dcoa Oct 21, 2025
a273c49
fix: validate error users to apply style
dcoa Oct 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/authz-module/components/AuthZTitle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,32 @@ describe('AuthZTitle', () => {
expect(onClick).toHaveBeenCalled();
});
});

it('renders action buttons with icons', () => {
const mockIcon = () => <span data-testid="mock-icon">Icon</span>;
const onClick = jest.fn();
const actions = [
{ label: 'Save', icon: mockIcon, onClick },
];

render(<AuthZTitle {...defaultProps} actions={actions} />);

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 = <div data-testid="custom-action">Custom Action</div>;
const actions = [
{ label: 'Save', onClick },
customAction,
];

render(<AuthZTitle {...defaultProps} actions={actions} />);

expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
expect(screen.getByTestId('custom-action')).toBeInTheDocument();
Copy link
Contributor

@jacobo-dominguez-wgu jacobo-dominguez-wgu Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A getByText query would be more focused on what the user sees on screen, since we are moving away from querying from testId

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for highlighting this, I will manage all that testId refactors in a separate PR, after the ulmo cut.

});
});
22 changes: 19 additions & 3 deletions src/authz-module/components/AuthZTitle.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,6 +11,7 @@ interface BreadcrumbLink {

interface Action {
label: string;
icon?: ComponentType;
onClick: () => void;
}

Expand All @@ -19,7 +20,7 @@ export interface AuthZTitleProps {
pageTitle: string;
pageSubtitle: string | ReactNode;
navLinks?: BreadcrumbLink[];
actions?: Action[];
actions?: (Action | ReactNode)[];
}

const AuthZTitle = ({
Expand All @@ -41,7 +42,22 @@ const AuthZTitle = ({
<Col xs={12} md={4}>
<div className="d-flex justify-content-md-end">
{
actions.map(({ label, onClick }) => <Button key={`authz-header-action-${label}`} onClick={onClick}>{label}</Button>)
actions.map((action) => {
if (isValidElement(action)) {
return action;
}

const { label, icon, onClick } = action as Action;
return (
<Button
key={`authz-header-action-${label}`}
iconBefore={icon}
onClick={onClick}
>
{label}
</Button>
);
})
}
</div>
</Col>
Expand Down
8 changes: 8 additions & 0 deletions src/authz-module/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
17 changes: 17 additions & 0 deletions src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,30 @@ 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<TeamMember[]> => {
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
return camelCaseObject(data.results);
};

export const assignTeamMembersRole = async (
data: AssignTeamMembersRoleRequest,
): Promise<PutAssignTeamMembersRoleResponse> => {
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<LibraryMetadata> => {
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));
Expand Down
74 changes: 73 additions & 1 deletion src/authz-module/data/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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'));
});
});
});
26 changes: 25 additions & 1 deletion src/authz-module/data/hooks.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -60,3 +64,23 @@ export const useLibrary = (libraryId: string) => useSuspenseQuery<LibraryMetadat
queryFn: () => 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) });
},
});
};
9 changes: 9 additions & 0 deletions src/authz-module/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ jest.mock('./components/TeamTable', () => ({
default: () => <div data-testid="team-table">MockTeamTable</div>,
}));

jest.mock('./components/AddNewTeamMemberModal', () => ({
__esModule: true,
AddNewTeamMemberTrigger: () => <div data-testid="add-team-member-trigger">MockAddNewTeamMemberTrigger</div>,
}));

describe('LibrariesTeamManager', () => {
beforeEach(() => {
initializeMockApp({
Expand Down Expand Up @@ -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();
});
});
15 changes: 11 additions & 4 deletions src/authz-module/libraries-manager/LibrariesTeamManager.tsx
Original file line number Diff line number Diff line change
@@ -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']);
Expand All @@ -21,11 +24,15 @@ const LibrariesTeamManager = () => {
activeLabel={pageTitle}
pageTitle={pageTitle}
pageSubtitle={libraryId}
actions={[]}
actions={
canManageTeam
? [<AddNewTeamMemberTrigger libraryId={libraryId} />]
: []
}
>
<Tabs
variant="tabs"
defaultActiveKey="team"
defaultActiveKey={hash ? 'permissions' : 'team'}
className="bg-light-100 px-5"
>
<Tab eventKey="team" title={intl.formatMessage(messages['library.authz.tabs.team'])} className="p-5">
Expand All @@ -34,7 +41,7 @@ const LibrariesTeamManager = () => {
<Tab eventKey="roles" title={intl.formatMessage(messages['library.authz.tabs.roles'])}>
Role tab.
</Tab>
<Tab eventKey="permissions" title={intl.formatMessage(messages['library.authz.tabs.permissions'])}>
<Tab id="libraries-permissions-tab" eventKey="permissions" title={intl.formatMessage(messages['library.authz.tabs.permissions'])}>
Permissions tab.
</Tab>
</Tabs>
Expand Down
Loading