Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { useNavigate } from 'react-router-dom';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useTeamMembers } from '@src/authz-module/data/hooks';
import {
EmailCell,
NameCell,
ActionCell,
RolesCell,
} from './Cells';

jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
}));

jest.mock('@src/authz-module/libraries-manager/context', () => ({
useLibraryAuthZ: jest.fn(),
}));

jest.mock('@src/authz-module/data/hooks', () => ({
useTeamMembers: jest.fn(),
}));

jest.mock('../hooks/useQuerySettings', () => ({
useQuerySettings: jest.fn(() => ({
querySettings: { page: 1, limit: 10 },
})),
}));

const mockNavigate = useNavigate as jest.Mock;
const mockUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
const mockUseTeamMembers = useTeamMembers as jest.Mock;

const renderWithIntl = (component: React.ReactElement) => render(
<IntlProvider locale="en" messages={{}}>
{component}
</IntlProvider>,
);

const mockTeamMember = {
username: 'john.doe',
fullName: 'John Doe',
email: '[email protected]',
roles: ['instructor', 'author'],
createdAt: '2023-01-01T00:00:00Z',
};

const mockSkeletonMember = {
username: 'skeleton',
fullName: '',
email: '',
roles: [],
createdAt: '',
};

const mockCellProps = {
row: { original: mockTeamMember },
};

const mockSkeletonCellProps = {
row: { original: mockSkeletonMember },
};

describe('Table Cells', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLibraryAuthZ.mockReturnValue({
username: 'current.user',
libraryId: 'lib123',
canManageTeam: true,
roles: [
{ role: 'instructor', name: 'Instructor' },
{ role: 'author', name: 'Author' },
],
});
mockUseTeamMembers.mockReturnValue({ isLoading: false });
mockNavigate.mockReturnValue(jest.fn());
});

describe('EmailCell', () => {
it('displays user email', () => {
renderWithIntl(<EmailCell {...mockCellProps} />);
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('shows loading skeleton for loading state', () => {
renderWithIntl(<EmailCell {...mockSkeletonCellProps} />);
expect(document.querySelector('.react-loading-skeleton')).toBeInTheDocument();
});
});

describe('NameCell', () => {
it('displays username for regular user', () => {
renderWithIntl(<NameCell {...mockCellProps} />);
expect(screen.getByText('john.doe')).toBeInTheDocument();
});

it('displays current user indicator for logged in user', () => {
const currentUserProps = {
...mockCellProps,
row: { original: { ...mockTeamMember, username: 'current.user' } },
};
renderWithIntl(<NameCell {...currentUserProps} />);
expect(screen.getByText('current.user')).toBeInTheDocument();
expect(screen.getByText('current.user').parentElement).toBeInTheDocument();
});
it('shows loading skeleton for loading state', () => {
renderWithIntl(<NameCell {...mockSkeletonCellProps} />);
expect(document.querySelector('.react-loading-skeleton')).toBeInTheDocument();
});
});

describe('ActionCell', () => {
it('renders edit button for manageable team member', () => {
renderWithIntl(<ActionCell {...mockCellProps} />);
const editButton = screen.getByRole('button');
expect(editButton).toBeInTheDocument();
expect(document.querySelector('.pgn__icon')).toBeInTheDocument();
expect(document.querySelector('svg')).toBeInTheDocument();
});

it('navigates to user page when edit button is clicked', async () => {
const user = userEvent.setup();
const navigateMock = jest.fn();
mockNavigate.mockReturnValue(navigateMock);
renderWithIntl(<ActionCell {...mockCellProps} />);
const editButton = screen.getByRole('button');
await user.click(editButton);
expect(navigateMock).toHaveBeenCalledWith('/authz/libraries/lib123/john.doe');
});

it('does not render edit button for current user', () => {
const currentUserProps = {
...mockCellProps,
row: { original: { ...mockTeamMember, username: 'current.user' } },
};
renderWithIntl(<ActionCell {...currentUserProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('does not render edit button when user cannot manage team', () => {
mockUseLibraryAuthZ.mockReturnValue({
username: 'current.user',
libraryId: 'lib123',
canManageTeam: false,
roles: [],
});
renderWithIntl(<ActionCell {...mockCellProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('does not render edit button during loading', () => {
mockUseTeamMembers.mockReturnValue({ isLoading: true });

renderWithIntl(<ActionCell {...mockCellProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

describe('RolesCell', () => {
it('displays role chips for user roles', () => {
renderWithIntl(<RolesCell {...mockCellProps} />);
expect(screen.getByText('Instructor')).toBeInTheDocument();
expect(screen.getByText('Author')).toBeInTheDocument();
});

it('shows loading skeleton for loading state', () => {
renderWithIntl(<RolesCell {...mockSkeletonCellProps} />);
expect(document.querySelector('.react-loading-skeleton')).toBeInTheDocument();
});

it('handles user with no roles', () => {
const noRolesProps = {
...mockCellProps,
row: { original: { ...mockTeamMember, roles: [] } },
};
renderWithIntl(<RolesCell {...noRolesProps} />);
expect(screen.queryByText('Instructor')).not.toBeInTheDocument();
expect(screen.queryByText('Author')).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Chip, Skeleton } from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';
import { TableCellValue, TeamMember } from '@src/types';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useNavigate } from 'react-router-dom';
import { useTeamMembers } from '@src/authz-module/data/hooks';
import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants';
import { useQuerySettings } from '../hooks/useQuerySettings';
import messages from '../messages';

type CellProps = TableCellValue<TeamMember>;

const EmailCell = ({ row }: CellProps) => (row.original?.username === SKELETON_ROWS[0].username ? (
<Skeleton width="180px" />
) : (
row.original.email
));

const NameCell = ({ row }: CellProps) => {
const intl = useIntl();
const { username } = useLibraryAuthZ();

if (row.original.username === SKELETON_ROWS[0].username) {
return <Skeleton width="180px" />;
}

if (row.original.username === username) {
return (
<span>
{username}
<span className="text-gray-500">{intl.formatMessage(messages['library.authz.team.table.username.current'])}</span>
</span>
);
}
return row.original.username;
};

const ActionCell = ({ row }: CellProps) => {
const intl = useIntl();
const {
libraryId, canManageTeam, username,
} = useLibraryAuthZ();
const navigate = useNavigate();
const { querySettings } = useQuerySettings();
const { isLoading } = useTeamMembers(libraryId, querySettings);
return (
canManageTeam && row.original.username !== username && !isLoading ? (
<Button
iconBefore={Edit}
variant="link"
size="sm"
onClick={() => navigate(`/authz/libraries/${libraryId}/${row.original.username}`)}
>
{intl.formatMessage(messages['authz.libraries.team.table.edit.action'])}
</Button>
) : null);
};

const RolesCell = ({ row }: CellProps) => {
const { roles } = useLibraryAuthZ();
const roleLabels = roles.reduce((acc, role) => ({ ...acc, [role.role]: role.name }), {} as Record<string, string>);
return (row.original.username === SKELETON_ROWS[0].username ? (
<Skeleton width="80px" />
) : (
row.original.roles.map((role) => (
<Chip key={`${row.original.username}-role-${role}`}>{roleLabels[role]}</Chip>
))
));
};

export {
EmailCell, NameCell, ActionCell, RolesCell,
};
73 changes: 10 additions & 63 deletions src/authz-module/libraries-manager/components/TeamTable/index.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,37 @@
import { useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import debounce from 'lodash.debounce';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
DataTable, Button, Chip, Skeleton,
DataTable,
TextFilter,
CheckboxFilter,
TableFooter,
} from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';
import { TableCellValue, TeamMember } from '@src/types';

import { useTeamMembers } from '@src/authz-module/data/hooks';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants';
import { useQuerySettings } from './hooks/useQuerySettings';
import TableControlBar from './components/TableControlBar';
import messages from './messages';

const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
username: 'skeleton',
name: '',
email: '',
roles: [],
}));
import {
ActionCell, EmailCell, NameCell, RolesCell,
} from './components/Cells';

const DEFAULT_PAGE_SIZE = 10;

type CellProps = TableCellValue<TeamMember>;

const EmailCell = ({ row }: CellProps) => (row.original?.username === SKELETON_ROWS[0].username ? (
<Skeleton width="180px" />
) : (
row.original.email
));

const NameCell = ({ row }: CellProps) => {
const intl = useIntl();
const { username } = useLibraryAuthZ();

if (row.original.username === SKELETON_ROWS[0].username) {
return <Skeleton width="180px" />;
}

if (row.original.username === username) {
return (
<span>
{username}
<span className="text-gray-500">{intl.formatMessage(messages['library.authz.team.table.username.current'])}</span>
</span>
);
}
return row.original.username;
};

const TeamTable = () => {
const intl = useIntl();
const {
libraryId, canManageTeam, username, roles,
libraryId, roles,
} = useLibraryAuthZ();
const roleLabels = roles.reduce((acc, role) => ({ ...acc, [role.role]: role.name }), {} as Record<string, string>);
const { showErrorToast } = useToastManager();

const { querySettings, handleTableFetch } = useQuerySettings();

const {
data: teamMembers, isLoading, isError, error, refetch,
data: teamMembers, isError, error, refetch,
} = useTeamMembers(libraryId, querySettings);

if (error) {
Expand All @@ -74,8 +41,6 @@ const TeamTable = () => {
const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS);
const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / DEFAULT_PAGE_SIZE) : 1;

const navigate = useNavigate();

const adaptedFilterChoices = useMemo(
() => roles.map((role) => ({
name: role.name,
Expand Down Expand Up @@ -108,18 +73,7 @@ const TeamTable = () => {
{
id: 'action',
Header: intl.formatMessage(messages['library.authz.team.table.action']),
// eslint-disable-next-line react/no-unstable-nested-components
Cell: ({ row }: CellProps) => (
canManageTeam && row.original.username !== username && !isLoading ? (
<Button
iconBefore={Edit}
variant="link"
size="sm"
onClick={() => navigate(`/authz/libraries/${libraryId}/${row.original.username}`)}
>
{intl.formatMessage(messages['authz.libraries.team.table.edit.action'])}
</Button>
) : null),
Cell: ActionCell,
},
]}
columns={
Expand All @@ -140,14 +94,7 @@ const TeamTable = () => {
{
Header: intl.formatMessage(messages['library.authz.team.table.roles']),
accessor: 'roles',
// eslint-disable-next-line react/no-unstable-nested-components
Cell: ({ row }: CellProps) => (row.original.username === SKELETON_ROWS[0].username ? (
<Skeleton width="80px" />
) : (
row.original.roles.map((role) => (
<Chip key={`${row.original.username}-role-${role}`}>{roleLabels[role]}</Chip>
))
)),
Cell: RolesCell,
Filter: CheckboxFilter,
filter: 'includesValue',
filterChoices: Object.values(adaptedFilterChoices),
Expand Down
7 changes: 7 additions & 0 deletions src/authz-module/libraries-manager/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ export const libraryPermissions: PermissionMetadata[] = [
{ key: 'manage_library_team', resource: 'library_team', description: 'View the list of users who have access to the library.' },
{ key: 'view_library_team', resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
];

export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
username: 'skeleton',
name: '',
email: '',
roles: [],
}));