Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a82cdaf
feat: add new team members endpoint
dcoa Sep 3, 2026
0ac9478
refactor: make team meambers table roles stacked
dcoa Sep 3, 2026
7196018
fix: display agregate message and fic styles for subtable
dcoa Sep 9, 2026
f2af7af
refactor: reorganize and extract scope aggregate info in a get function
dcoa Sep 9, 2026
3d676c0
fix: use footer from paragon table
dcoa Sep 9, 2026
d35155c
refactor: organize team members feature
dcoa Sep 9, 2026
e3a6d16
refactor: AssignedRolesCell unique component
dcoa Sep 10, 2026
8226015
fix: table colum widths fixed only for md-xl screens
dcoa Sep 10, 2026
d9e8275
test: increase coverage
dcoa Sep 10, 2026
023eadf
revert: keep Visibility icon change out of the scope
dcoa Sep 10, 2026
ca289c4
refactor: use barriel for team-members
dcoa Sep 10, 2026
69a86f2
fix: restore the error boundary for 403/401 error
dcoa Sep 10, 2026
30ae3a1
refactor: view button and remove unecesary component
dcoa Sep 10, 2026
f76dcfa
refactor: use button insted f hyperlink for table taggle
dcoa Sep 10, 2026
ba9129d
fix: refactor some errors in variables and docstrings
dcoa Sep 10, 2026
cc5d81c
refactor: remove unnecessary styles
dcoa Sep 10, 2026
d0d7c3c
test: clean up authz-home test
dcoa Sep 10, 2026
6297617
refactor: extract row expassion to a shared hook
dcoa Sep 10, 2026
05502ee
refactor: fix aggregate labels
dcoa Sep 14, 2026
5b54ea9
refactor: update doctrings and remove default assigment limit
dcoa Sep 18, 2026
3349413
fix: update remaining imports after rebase and remove unused types
dcoa Sep 22, 2026
fae01bd
test: fix subtable uri redirect validation
dcoa Sep 22, 2026
f9f4d09
refactor: use a more semantic name for the expandable button for tables
dcoa Sep 22, 2026
17870cb
style: fix linter after rebase
dcoa Sep 28, 2026
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
64 changes: 57 additions & 7 deletions src/authz-module/authz-home/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { screen } from '@testing-library/react';
import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks';
import { useTeamMembersAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks';
import { renderWithAllProviders } from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext';
import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions';
import { CustomErrors } from '@src/constants';
import AuthzHome from './index';
import messages from './messages';

Expand All @@ -14,8 +16,12 @@ jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({
}),
}));

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

jest.mock('@src/authz-module/data/hooks', () => ({
useAllRoleAssignments: jest.fn(),
useTeamMembersAssignments: jest.fn(),
useOrgs: jest.fn(),
useScopes: jest.fn(),
}));
Expand All @@ -38,6 +44,17 @@ const emptyScopesResponse = {
isFetchingNextPage: false,
};

// Nothing is allowed while the check is in flight; cases override only what they are about.
const permissionsInFlight = {
isCourseViewAllowed: false,
isLibraryViewAllowed: false,
isLoading: true,
};

const mockViewTeamPermissions = (overrides: Partial<typeof permissionsInFlight> = {}) => {
(useViewTeamPermissions as jest.Mock).mockReturnValue({ ...permissionsInFlight, ...overrides });
};

const renderAuthzHome = () => renderWithAllProviders(
<ToastManagerProvider>
<AuthzHome />
Expand All @@ -46,7 +63,10 @@ const renderAuthzHome = () => renderWithAllProviders(

describe('AuthzHome', () => {
beforeEach(() => {
(useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
// Call history is asserted on below, so it must not carry over between cases.
jest.clearAllMocks();
mockViewTeamPermissions({ isCourseViewAllowed: true, isLibraryViewAllowed: true, isLoading: false });
(useTeamMembersAssignments as jest.Mock).mockReturnValue(emptyResponse);
(useOrgs as jest.Mock).mockReturnValue(emptyResponse);
(useScopes as jest.Mock).mockReturnValue(emptyScopesResponse);
});
Expand Down Expand Up @@ -77,14 +97,44 @@ describe('AuthzHome', () => {
expect(screen.getByRole('button', { name: 'Libraries' })).toBeInTheDocument();
});

it('denies access to the whole page when the user may view neither courses nor libraries', () => {
mockViewTeamPermissions({ isLoading: false });

// Neither tab has anything to show, so the page hands off to the error boundary.
expect(() => renderAuthzHome()).toThrow(CustomErrors.NO_ACCESS);
});

it('shows a loader instead of the page while the permission check is in flight', () => {
mockViewTeamPermissions();

renderAuthzHome();

expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('Loading')).toBeInTheDocument();
// Nothing behind the gate renders yet, so no listing request is fired for a user
// who may turn out to be denied.
expect(screen.queryByText(messages['authz.manage.page.title'].defaultMessage)).not.toBeInTheDocument();
expect(useTeamMembersAssignments).not.toHaveBeenCalled();
});

it('renders the page when only library roles may be viewed', () => {
mockViewTeamPermissions({ isLibraryViewAllowed: true, isLoading: false });

renderAuthzHome();

expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument();
});

it('renders the TeamMembersTable component in the team members tab', () => {
renderAuthzHome();
expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Username')).toBeInTheDocument();
expect(screen.getByText('Email')).toBeInTheDocument();
expect(screen.getAllByText('Organization').length).toBe(2); // Header and org filter;
expect(screen.getAllByText('Scope').length).toBe(2); // Header and scope filter;
expect(screen.getAllByText('Role').length).toBe(2); // Header and role filter;
expect(screen.getByText('Assigned roles')).toBeInTheDocument();
// Org, scope and role are filter controls only — they are no longer column headers.
expect(screen.getAllByText('Organization').length).toBe(1);
expect(screen.getAllByText('Scope').length).toBe(1);
expect(screen.getAllByText('Role').length).toBe(1);
expect(screen.getByText('Actions')).toBeInTheDocument();
});
});
26 changes: 25 additions & 1 deletion src/authz-module/authz-home/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useIntl } from '@openedx/frontend-base';
import { Tab, Tabs } from '@openedx/paragon';
import { useLocation, useSearchParams } from 'react-router-dom';
import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable';
import { TeamMembersTable } from '@src/authz-module/team-members';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions';
import { CustomErrors } from '@src/constants';
import LoadingPage from '@src/components/LoadingPage';
import RolesPermissions from '@src/authz-module/roles-permissions/RolesPermissions';
import AuthZLayout from '@src/authz-module/components/AuthZLayout';

Expand All @@ -20,6 +23,27 @@ const AuthzHome = () => {

const pageTitle = intl.formatMessage(messages['authz.manage.page.title']);

const {
isCourseViewAllowed, isLibraryViewAllowed, isLoading: isLoadingPermissions,
} = useViewTeamPermissions();

/**
* It gates the whole page whether the user is allowed to view the team members. If
* the user is not allowed, an error is thrown to display the access denied message.
*
* Rendering is held until the check settles rather than started optimistically, so a
* user who turns out to be denied never fires the listing and filter requests behind
* the page. Both flags read false while it is in flight, which would otherwise deny
* everyone for a frame.
*/
if (isLoadingPermissions) {
return <LoadingPage />;
}

if (!isCourseViewAllowed && !isLibraryViewAllowed) {
throw new Error(CustomErrors.NO_ACCESS);
}

return (
<AuthZLayout
pageTitle={pageTitle}
Expand Down
79 changes: 79 additions & 0 deletions src/authz-module/components/ExpandableButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { screen } from '@testing-library/react';
import { renderWrapper } from '@src/testUtils';
import userEvent from '@testing-library/user-event';
import ExpandableButton from './ExpandableButton';

describe('ExpandableButton', () => {
const mockOnClick = jest.fn();
const defaultProps = {
label: 'View more details',
onClick: mockOnClick,
isExpanded: false,
};

beforeEach(() => {
jest.clearAllMocks();
});

describe('rendering', () => {
it('exposes the label as a button, not a link, since it triggers an action in place', () => {
renderWrapper(<ExpandableButton {...defaultProps} />);

expect(screen.getByRole('button', { name: 'View more details' })).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});

it('shows a chevron and announces itself as closed while collapsed', () => {
renderWrapper(<ExpandableButton {...defaultProps} />);

const button = screen.getByRole('button', { name: 'View more details' });
expect(button.querySelector('svg')).toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'false');
});

it('announces itself as open once expanded', () => {
renderWrapper(<ExpandableButton {...defaultProps} isExpanded />);

const button = screen.getByRole('button', { name: 'View more details' });
expect(button.querySelector('svg')).toBeInTheDocument();
expect(button).toHaveAttribute('aria-expanded', 'true');
});
});

describe('user interactions', () => {
it('calls onClick handler when user clicks the button', async () => {
const user = userEvent.setup();
renderWrapper(<ExpandableButton {...defaultProps} />);

await user.click(screen.getByRole('button', { name: 'View more details' }));

expect(mockOnClick).toHaveBeenCalledTimes(1);
});

it('handles multiple clicks correctly', async () => {
const user = userEvent.setup();
renderWrapper(<ExpandableButton {...defaultProps} />);

const button = screen.getByRole('button', { name: 'View more details' });
await user.click(button);
await user.click(button);
await user.click(button);

expect(mockOnClick).toHaveBeenCalledTimes(3);
});

it('can be reached and activated with the keyboard alone', async () => {
const user = userEvent.setup();
renderWrapper(<ExpandableButton {...defaultProps} />);

await user.tab();
expect(screen.getByRole('button', { name: 'View more details' })).toHaveFocus();

await user.keyboard('{Enter}');
expect(mockOnClick).toHaveBeenCalledTimes(1);

await user.keyboard(' ');
expect(mockOnClick).toHaveBeenCalledTimes(2);
});
});
});
32 changes: 32 additions & 0 deletions src/authz-module/components/ExpandableButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Button } from '@openedx/paragon';
import { ExpandLess, ExpandMore } from '@openedx/paragon/icons';

interface ExpandableButtonProps {
label: string;
onClick: () => void;
isExpanded: boolean;
}

/**
* Link-styled trigger that expands something in place: a table row, a list of items.
*
* A real `<button>` rather than an anchor — it navigates nowhere, and an anchor with no
* `href` is neither focusable nor activatable from the keyboard. `size="inline"` drops the
* button padding so it keeps sitting on the text baseline inside a table cell.
*
* `isExpanded` drives both halves of the state report, which have to agree: the chevron
* for sighted users and `aria-expanded` for a screen reader.
*/
const ExpandableButton = ({ label, onClick, isExpanded }: ExpandableButtonProps) => (
<Button
variant="link"
size="inline"
onClick={onClick}
iconAfter={isExpanded ? ExpandLess : ExpandMore}
aria-expanded={isExpanded}
>
{label}
</Button>
);

export default ExpandableButton;
Loading
Loading