diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index 43f3da1d..818984da 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -1,40 +1,74 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { renderWrapper } from '@src/setupTest'; +import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; +import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext'; +import { renderWithAllProviders } from '@src/setupTest'; +import userEvent from '@testing-library/user-event'; import AuthzHome from './index'; +import messages from './messages'; -jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) { - return
{children}
; -}); +jest.mock('@src/authz-module/data/hooks', () => ({ + useAllRoleAssignments: jest.fn(), + useOrgs: jest.fn(), + useScopes: jest.fn(), +})); -jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() { - return
Roles & Permissions Content
; -}); +const emptyResponse = { + data: { + results: [], count: 0, next: null, previous: null, + }, + error: null, + isLoading: false, + refetch: jest.fn(), +}; -jest.mock('@openedx/paragon', () => ({ - Tab: ({ children, title } : { children: React.ReactNode, title: string }) =>
{title}: {children}
, - Tabs: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); +const renderAuthzHome = () => renderWithAllProviders( + + + , +); describe('AuthzHome', () => { + beforeEach(() => { + (useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse); + (useOrgs as jest.Mock).mockReturnValue(emptyResponse); + (useScopes as jest.Mock).mockReturnValue(emptyResponse); + }); + it('renders without crashing', () => { - renderWrapper(); + renderAuthzHome(); }); it('renders the main layout and tabs', () => { - renderWrapper(); - expect(screen.getByTestId('authz-layout')).toBeInTheDocument(); - expect(screen.getByTestId('tabs')).toBeInTheDocument(); + renderAuthzHome(); + expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument(); }); it('renders both tab panels', () => { - renderWrapper(); - const tabs = screen.getAllByTestId('tab'); - expect(tabs).toHaveLength(2); + renderAuthzHome(); + expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument(); + expect(screen.getAllByRole('tab')).toHaveLength(3); // 2 + tab invisible for more... + }); + + it('renders the RolesPermissions component in the permissions tab', async () => { + const user = userEvent.setup(); + renderAuthzHome(); + await user.click(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)); + expect(screen.getByRole('button', { name: 'Courses' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Libraries' })).toBeInTheDocument(); }); - it('renders the RolesPermissions component in the permissions tab', () => { - renderWrapper(); - expect(screen.getByTestId('roles-permissions')).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('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('Actions')).toBeInTheDocument(); }); }); diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx index f6cc7169..27a1a7fd 100644 --- a/src/authz-module/authz-home/index.tsx +++ b/src/authz-module/authz-home/index.tsx @@ -1,6 +1,8 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Tab, Tabs } from '@openedx/paragon'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable'; +import AddRoleButton from '@src/authz-module/components/AddRoleButton'; import RolesPermissions from '../roles-permissions/RolesPermissions'; import AuthZLayout from '../components/AuthZLayout'; @@ -9,24 +11,19 @@ import messages from './messages'; const AuthzHome = () => { const { hash } = useLocation(); const intl = useIntl(); + const [searchParams] = useSearchParams(); + const presetScope = searchParams.get('scope') || undefined; - const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || ''; const pageTitle = intl.formatMessage(messages['authz.manage.page.title']); return ( -
+
, - // ] + [] } > { defaultActiveKey={hash ? 'permissionsRoles' : 'team'} className="bg-light-100 px-5" > - - {/* TODO: once TeamTable is refactored we can call it here. For now, this tab will be empty. */} - {/* */} + + diff --git a/src/authz-module/authz-home/messages.ts b/src/authz-module/authz-home/messages.ts index 39eb74af..bccd2d64 100644 --- a/src/authz-module/authz-home/messages.ts +++ b/src/authz-module/authz-home/messages.ts @@ -3,23 +3,23 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ 'authz.manage.page.title': { id: 'authz.manage.page.title', - defaultMessage: 'Library Team Management', - description: 'Libraries AuthZ page title', + defaultMessage: 'Roles and Permissions Management', + description: 'AuthZ home page title', }, 'authz.breadcrumb.root': { id: 'authz.breadcrumb.root', defaultMessage: 'Manage Access', - description: 'Libraries AuthZ root breadcrumb', + description: 'AuthZ root breadcrumb', }, 'authz.tabs.team': { id: 'authz.tabs.team', defaultMessage: 'Team Members', - description: 'Libraries AuthZ title for the team management tab', + description: 'AuthZ title for the team management tab', }, 'authz.tabs.permissionsRoles': { id: 'authz.tabs.permissionsRoles', defaultMessage: 'Roles and Permissions', - description: 'Libraries AuthZ title for the roles tab', + description: 'AuthZ title for the roles tab', }, }); diff --git a/src/authz-module/components/AddRoleButton.test.tsx b/src/authz-module/components/AddRoleButton.test.tsx new file mode 100644 index 00000000..7765faf4 --- /dev/null +++ b/src/authz-module/components/AddRoleButton.test.tsx @@ -0,0 +1,134 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNavigate } from 'react-router-dom'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import AddRoleButton from './AddRoleButton'; + +// Mock react-router-dom navigation +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); + +describe('AddRoleButton', () => { + const mockNavigate = jest.fn(); + + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + beforeEach(() => { + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the assign role button with correct text', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button).toBeInTheDocument(); + }); + + it('displays the plus icon', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders correctly when presetUsername is provided', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button).toBeInTheDocument(); + }); + }); + + describe('navigation behavior', () => { + it('navigates to assign role page without username when clicked', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('navigates to assign role page with username query parameter when presetUsername is provided', async () => { + const user = userEvent.setup(); + const presetUsername = 'john.doe'; + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + }); + + it('handles special characters in presetUsername correctly', async () => { + const user = userEvent.setup(); + const presetUsername = 'user@example.com'; + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + }); + }); + + describe('user interactions', () => { + it('responds to keyboard navigation', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + + await user.tab(); + expect(button).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('responds to spacebar activation', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + button.focus(); + + await user.keyboard(' '); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('handles multiple clicks gracefully', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + + await user.click(button); + await user.click(button); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(3); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser'); + }); + }); +}); diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx new file mode 100644 index 00000000..8a44e750 --- /dev/null +++ b/src/authz-module/components/AddRoleButton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@openedx/paragon'; +import { Plus } from '@openedx/paragon/icons'; + +import baseMessages from '@src/authz-module/messages'; +import { useNavigate } from 'react-router-dom'; + +interface AddRoleButtonProps { + presetUsername?: string; +} + +const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + + const handleClick = () => { + const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`; + navigate(assignRolePath); + }; + + return ( + + ); +}; + +export default AddRoleButton; diff --git a/src/authz-module/components/AuthZLayout.tsx b/src/authz-module/components/AuthZLayout.tsx index 9845b90c..06b572cc 100644 --- a/src/authz-module/components/AuthZLayout.tsx +++ b/src/authz-module/components/AuthZLayout.tsx @@ -14,9 +14,10 @@ interface AuthZLayoutProps extends AuthZTitleProps { const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => ( <> {children} diff --git a/src/authz-module/components/AuthZTitle.tsx b/src/authz-module/components/AuthZTitle.tsx index a169c840..c4aaa734 100644 --- a/src/authz-module/components/AuthZTitle.tsx +++ b/src/authz-module/components/AuthZTitle.tsx @@ -21,7 +21,7 @@ interface Action { } export interface AuthZTitleProps { - activeLabel: string; + activeLabel?: string; pageTitle: string; pageSubtitle: string | ReactNode; navLinks?: BreadcrumbLink[]; @@ -53,8 +53,8 @@ const AuthZTitle = ({

{pageTitle}

{typeof pageSubtitle === 'string' - ? <>

{pageSubtitle}

- : <>
{pageSubtitle}
} + ? <> { pageSubtitle !== '' &&
}

{pageSubtitle}

+ : <>{ pageSubtitle !== '' &&
}
{pageSubtitle}
}
diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx new file mode 100644 index 00000000..00e7ad3d --- /dev/null +++ b/src/authz-module/components/TableCells.test.tsx @@ -0,0 +1,420 @@ +import { screen } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import userEvent from '@testing-library/user-event'; +import { + NameCell, + ViewActionCell, + RoleCell, + OrgCell, + ScopeCell, +} from './TableCells'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +describe('TableCells Components', () => { + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('NameCell', () => { + const mockUserRole = { + isSuperadmin: false, + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 27, + fullName: 'John Doe', + username: 'johndoe', + email: 'johndoe@example.com', + }; + const mockCellProps = { + row: { + original: mockUserRole, + }, + }; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'testuser@example.com', + }, + }); + }); + + it('displays the full name when available', () => { + renderWrapper(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('displays username when full name is not available', () => { + const propsWithoutFullName = { + row: { + original: { + ...mockUserRole, + fullName: undefined, + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + }); + + it('displays username when full name is empty string', () => { + const propsWithEmptyFullName = { + row: { + original: { + ...mockUserRole, + fullName: '', + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + }); + + it('shows current user indicator when username matches authenticated user', () => { + const currentUserProps = { + row: { + original: { + ...mockUserRole, + username: 'testuser', + fullName: 'Test User', + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('Test User')).toBeInTheDocument(); + expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); + }); + + it('does not show current user indicator when username does not match authenticated user', () => { + renderWrapper(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); + + it('shows current user indicator with username fallback when no full name is provided', () => { + const currentUserPropsNoFullName = { + row: { + original: { + ...mockUserRole, + username: 'testuser', + fullName: undefined, + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); + }); + + it('handles missing username in authenticated user gracefully', () => { + const contextWithoutUsername = { + authenticatedUser: { + username: undefined, + email: 'testuser@example.com', + }, + }; + + renderWrapper(, contextWithoutUsername); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); + }); + + describe('ViewActionCell', () => { + const mockUserRole = { + isSuperadmin: false, + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 27, + fullName: 'John Doe', + username: 'johndoe', + email: 'johndoe@example.com', + }; + + const mockCellProps = { + row: { + original: mockUserRole, + }, + }; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'testuser@example.com', + }, + }); + mockNavigate.mockClear(); + }); + + it('renders view action button', () => { + renderWrapper(); + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toBeInTheDocument(); + }); + + it('has correct accessibility attributes', () => { + renderWrapper(); + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toHaveAttribute('aria-label'); + }); + + it('navigates to user profile when clicked', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/johndoe'); + }); + + it('navigates with correct username for different user', async () => { + const user = userEvent.setup(); + const differentUserProps = { + row: { + original: { + ...mockUserRole, + username: 'janedoe', + }, + }, + }; + + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/janedoe'); + }); + + it('handles empty username gracefully', async () => { + const user = userEvent.setup(); + const emptyUsernameProps = { + row: { + original: { + ...mockUserRole, + username: '', + }, + }, + }; + + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/'); + }); + + it('handles special characters in username', async () => { + const user = userEvent.setup(); + const specialUsernameProps = { + row: { + original: { + ...mockUserRole, + username: 'user+with@special.chars', + }, + }, + }; + + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/user+with@special.chars'); + }); + }); + + describe('RoleCell', () => { + const mockCell = { + getCellProps: jest.fn(() => ({ 'data-testid': 'role-cell' })), + }; + + it('renders the role label for a mapped role', () => { + const props = { + value: 'library_admin', + cell: mockCell, + row: { + original: { + role: 'library_admin', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + expect(screen.getByText('Library Admin')).toBeInTheDocument(); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Library Admin' }); + }); + + it('renders empty string for unmapped role', () => { + const props = { + value: 'unknown_role', + cell: mockCell, + row: { + original: { + role: 'unknown_role', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + const cellElement = screen.getByTestId('role-cell'); + expect(cellElement).toHaveTextContent(''); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': '' }); + }); + + it('applies cell props correctly', () => { + const props = { + value: 'course_staff', + cell: mockCell, + row: { + original: { + role: 'course_staff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Course Staff' }); + }); + }); + + describe('OrgCell', () => { + it('displays "All Organizations" for Django superuser role', () => { + const props = { + value: 'Test Org', + row: { + original: { + role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); + }); + + it('displays "All Organizations" for Django global staff role', () => { + const props = { + value: 'Test Org', + row: { + original: { + role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); + }); + + it('displays the actual org value for non-Django roles', () => { + const props = { + value: 'Test Organization', + row: { + original: { + role: 'library_admin', org: 'Test Organization', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('Test Organization')).toBeInTheDocument(); + expect(screen.queryByText('All Organizations')).not.toBeInTheDocument(); + }); + }); + + describe('ScopeCell', () => { + it('displays "Global" for Django superuser role', () => { + const props = { + value: 'library', + row: { + original: { + role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.queryByText('library')).not.toBeInTheDocument(); + }); + + it('displays "Global" for Django global staff role', () => { + const props = { + value: 'course', + row: { + original: { + role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.queryByText('course')).not.toBeInTheDocument(); + }); + + it('displays the actual scope value for non-Django roles', () => { + const props = { + value: 'Course Scope', + row: { + original: { + role: 'course_admin', org: 'Test Org', scope: 'Course Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Course Scope')).toBeInTheDocument(); + expect(screen.queryByText('Global')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx new file mode 100644 index 00000000..a1a36947 --- /dev/null +++ b/src/authz-module/components/TableCells.tsx @@ -0,0 +1,96 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton } from '@openedx/paragon'; +import { AppContext } from '@edx/frontend-platform/react'; +import { + RemoveRedEye, +} from '@openedx/paragon/icons'; +import { TableCellValue, AppContextType, UserRole } from '@src/types'; +import { useNavigate } from 'react-router-dom'; +import { useContext, useMemo } from 'react'; +import { DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants'; +import messages from './messages'; +import { RESOURCE_ICONS } from './constants'; + +type CellProps = TableCellValue; +type CellPropsWithValue = CellProps & { + value: string; +}; +type ExtendedCellProps = CellPropsWithValue & { + cell: { + getCellProps: (props?: Record) => Record; + }; +}; + +const NameCell = ({ row }: CellProps) => { + const intl = useIntl(); + const { authenticatedUser } = useContext(AppContext) as AppContextType; + const username = authenticatedUser?.username; + + if (row.original.username === username) { + return ( + + {row.original.fullName || row.original.username} + {intl.formatMessage(messages['authz.table.username.current'])} + + ); + } + return row.original.fullName || row.original.username; +}; + +const ViewActionCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + const navigate = useNavigate(); + const viewPath = `/authz/user/${row.original.username}`; + return ( + navigate(viewPath)} + /> + ); +}; + +const OrgCell = ({ value, row }: CellPropsWithValue) => { + const { formatMessage } = useIntl(); + return ( + + {DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value} + + ); +}; + +const ScopeCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + + const { scopeText, iconSrc } = useMemo(() => { + if (DJANGO_MANAGED_ROLES.includes(row.original.role)) { + return { + scopeText: formatMessage(messages['authz.user.table.scope.global.label']), + iconSrc: RESOURCE_ICONS.GLOBAL, + }; + } + const scopeIcon = row.original.role?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + return { + scopeText: row.original.scope, + iconSrc: scopeIcon, + }; + }, [row.original.role, row.original.scope, formatMessage]); + + return ( + + {iconSrc && } + {scopeText} + + ); +}; + +const RoleCell = ({ value, cell }: ExtendedCellProps) => ( + + {MAP_ROLE_KEY_TO_LABEL[value] || ''} + +); + +export { + NameCell, ViewActionCell, ScopeCell, RoleCell, OrgCell, +}; diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx new file mode 100644 index 00000000..ab56c751 --- /dev/null +++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx @@ -0,0 +1,164 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; + +describe('MultipleChoiceFilter', () => { + const defaultProps = { + filterButtonText: 'Test Filter', + filterChoices: [ + { displayName: 'Option 1', value: 'option1' }, + { displayName: 'Option 2', value: 'option2', description: 'desc' }, + ], + filterValue: [], + setFilter: jest.fn(), + }; + + const groupedChoices = [ + { displayName: 'Group A Option', value: 'groupA1', groupName: 'Group A' }, + { displayName: 'Group B Option', value: 'groupB1', groupName: 'Group B' }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('displays filter button text', () => { + renderWrapper(); + expect(screen.getByText('Custom Filter')).toBeInTheDocument(); + }); + + it('shows count when items are selected', () => { + renderWrapper(); + expect(screen.getByText('Test Filter (1)')).toBeInTheDocument(); + }); + + it('opens dropdown menu when clicked', async () => { + const user = userEvent.setup(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('displays search input when searchable', async () => { + const user = userEvent.setup(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('handles grouped choices', async () => { + const user = userEvent.setup(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + expect(screen.getByText('Group A')).toBeInTheDocument(); + expect(screen.getByText('Group B')).toBeInTheDocument(); + const CheckA1 = screen.getByText('Group A Option'); + await user.click(CheckA1); + expect(defaultProps.setFilter).toHaveBeenCalledWith( + ['groupA1'], + { + groupName: 'test filter', + value: 'groupA1', + displayName: 'Group A Option', + }, + ); + }); + + it('calls setFilter when option is selected', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + const option = screen.getByText('Option 1'); + await user.click(option); + expect(mockSetFilter).toHaveBeenCalled(); + }); + + it('adds option to selection on first click', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + const checkbox = screen.getByLabelText('Option 1'); + await user.click(checkbox); + expect(mockSetFilter).toHaveBeenCalledWith( + ['option1'], + { + groupName: 'test filter', + value: 'option1', + displayName: 'Option 1', + }, + ); + }); + + it('removes option from selection when already selected', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter (1)'); + await user.click(button); + const checkbox = screen.getByLabelText('Option 1'); + await user.click(checkbox); + expect(mockSetFilter).toHaveBeenCalledWith( + [], + { + groupName: 'test filter', + value: 'option1', + displayName: 'Option 1', + }, + ); + }); + + it('handles multiple selections correctly', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter (1)'); + await user.click(button); + const checkbox = screen.getByLabelText('Option 2'); + await user.click(checkbox); + expect(mockSetFilter).toHaveBeenCalledWith( + ['option1', 'option2'], + { + groupName: 'test filter', + value: 'option2', + displayName: 'Option 2', + }, + ); + }); + + it('calls onSearchChange when search input changes', async () => { + const user = userEvent.setup(); + const mockOnSearchChange = jest.fn(); + renderWrapper( + , + ); + const button = screen.getByRole('button', { name: /test filter/i }); + await user.click(button); + const searchInput = screen.getByRole('textbox'); + await user.type(searchInput, 'test search'); + expect(mockOnSearchChange).toHaveBeenCalled(); + expect(mockOnSearchChange).toHaveBeenLastCalledWith('test search'); + }); +}); diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx new file mode 100644 index 00000000..cb77b67c --- /dev/null +++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx @@ -0,0 +1,144 @@ +import { + Dropdown, Form, Icon, Stack, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { FilterList, Info, Search } from '@openedx/paragon/icons'; +import { useState } from 'react'; +import messages from '../messages'; +import { FilterChoice, MultipleChoiceFilterProps } from './types'; + +const MultipleChoiceFilter = ({ + filterButtonText, + filterChoices, + filterValue, + setFilter, + isGrouped = false, + isSearchable = false, + onSearchChange, + iconSrc, + disabled = false, +}: MultipleChoiceFilterProps) => { + const [searchValue, setSearchValue] = useState(undefined); + const { formatMessage } = useIntl(); + + const checkedBoxes = filterValue || []; + const handleClickCheckbox = (value, displayName) => { + const newValue = { + groupName: filterButtonText?.toLocaleLowerCase() || '', + value, + displayName, + }; + if (checkedBoxes.includes(value)) { + const newCheckedBoxes = checkedBoxes.filter((val) => val !== value); + return setFilter(newCheckedBoxes, newValue); + } + const newCheckedBoxes = [...checkedBoxes, value]; + return setFilter(newCheckedBoxes, newValue); + }; + + const getGroupedChoices = () => { + const groupedFilterChoices = filterChoices.reduce((groups, choice) => { + const groupName = choice.groupName || 'Ungrouped'; + const icon = choice.groupIcon || undefined; + if (!groups.has(groupName)) { + groups.set(groupName, { groupName, options: [], icon }); + } + groups.get(groupName)!.options.push({ + displayName: choice.displayName, + value: choice.value, + description: choice.description, + }); + return groups; + }, new Map; icon?: any }>()); + return Array.from(groupedFilterChoices.values()); + }; + + return ( + + 0 ? 'primary' : 'outline-primary'}> + + {iconSrc && } + {filterButtonText} + {checkedBoxes.length > 0 && ` (${checkedBoxes.length})`} + + + + + + {isSearchable && ( + } + placeholder={formatMessage(messages['authz.table.controlbar.search'])} + onChange={(e) => { + setSearchValue(e.target.value); + onSearchChange?.(e.target.value); + }} + value={searchValue} + /> + )} + + + {formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })} + + {!isGrouped ? filterChoices.map(({ + displayName, value, description, + }) => ( + handleClickCheckbox(value, displayName)} + aria-label={displayName} + disabled={checkedBoxes.includes(value) ? false : disabled} + > +
+ {displayName} + { description && {description} } +
+
+ )) + : getGroupedChoices().map(({ groupName, icon, options }) => ( +
+
+ {icon && } + {groupName} +
+ {options.map(({ displayName, value, description }) => ( + handleClickCheckbox(value, displayName)} + disabled={checkedBoxes.includes(value) ? false : disabled} + aria-label={displayName} + > +
+ {displayName} + { description && {description} } +
+
+ ))} +
+ ))} + { isSearchable && ( +
+ {formatMessage(messages['authz.table.controlbar.filters.more.results'])} + +
+ )} +
+
+ +
+ ); +}; + +export default MultipleChoiceFilter; diff --git a/src/authz-module/components/TableControlBar/OrgFilter.test.tsx b/src/authz-module/components/TableControlBar/OrgFilter.test.tsx new file mode 100644 index 00000000..228b9d69 --- /dev/null +++ b/src/authz-module/components/TableControlBar/OrgFilter.test.tsx @@ -0,0 +1,63 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import OrgFilter from './OrgFilter'; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useOrgs: () => ({ + data: { + count: 2, + next: null, + previous: null, + results: [ + { id: 'org1', name: 'Organization 1' }, + { id: 'org2', name: 'Organization 2' }, + ], + }, + }), +})); + +describe('OrgFilter', () => { + const defaultProps = { + filterButtonText: 'Organizations', + filterValue: [], + setFilter: jest.fn(), + disabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('displays filter options', () => { + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('handles search input', async () => { + const user = userEvent.setup(); + renderWrapper(); + // Look for search input if it exists + const searchInputs = screen.queryAllByRole('textbox'); + if (searchInputs.length > 0) { + await user.type(searchInputs[0], 'test search'); + expect(searchInputs[0]).toHaveValue('test search'); + } + }); + + it('calls setFilter when filter changes', () => { + const mockSetFilter = jest.fn(); + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/components/TableControlBar/OrgFilter.tsx b/src/authz-module/components/TableControlBar/OrgFilter.tsx new file mode 100644 index 00000000..cc0cd52e --- /dev/null +++ b/src/authz-module/components/TableControlBar/OrgFilter.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react'; +import { Business } from '@openedx/paragon/icons'; +import { useOrgs } from '@src/authz-module/data/hooks'; +import { DEFAULT_FILTER_PAGE_SIZE } from '@src/authz-module/constants'; +import { MultipleChoiceFilterProps } from './types'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; + +type OrgFilterProps = Omit; + +const OrgFilter = ({ + filterButtonText, filterValue, setFilter, disabled, +}: OrgFilterProps) => { + const [searchValue, setSearchValue] = React.useState(undefined); + const { + data: orgsData = { + count: 0, next: null, previous: null, results: [], + }, + } = useOrgs(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE); + const filterChoices = useMemo(() => orgsData?.results?.map((org) => ({ + displayName: org.name, + value: org.shortName, + })) || [], [orgsData]); + + const handleSearchChange = (value: string) => { + setSearchValue(value); + }; + + return ( + + ); +}; + +export default OrgFilter; diff --git a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx new file mode 100644 index 00000000..1ba0724f --- /dev/null +++ b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import RolesFilter from './RolesFilter'; + +describe('RolesFilter', () => { + const defaultProps = { + filterButtonText: 'Roles', + filterValue: [], + setFilter: jest.fn(), + disabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Roles')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Roles')).toBeInTheDocument(); + }); + + it('displays filter button text', () => { + renderWrapper(); + expect(screen.getByText('Select Roles')).toBeInTheDocument(); + }); + + it('calls setFilter when filter changes', () => { + const mockSetFilter = jest.fn(); + renderWrapper(); + expect(screen.getByText('Roles')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/components/TableControlBar/RolesFilter.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx new file mode 100644 index 00000000..d0e061e9 --- /dev/null +++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Person } from '@openedx/paragon/icons'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; +import { MultipleChoiceFilterProps } from './types'; +import { getRolesFiltersOptions } from '../constants'; + +type RolesFilterProps = Omit; + +const RolesFilter = ({ + filterButtonText, filterValue, setFilter, disabled, +}: RolesFilterProps) => { + const intl = useIntl(); + const rolesOptions = useMemo(() => getRolesFiltersOptions(intl), [intl]); + return ( + + ); +}; + +export default RolesFilter; diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx new file mode 100644 index 00000000..18654ad4 --- /dev/null +++ b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx @@ -0,0 +1,67 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import ScopesFilter from './ScopesFilter'; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useScopes: () => ({ + data: { + results: [ + { + externalKey: 'course:123', + name: 'Test Course', + organization: { name: 'Test Org' }, + }, + { + externalKey: 'library:456', + name: 'Test Library', + organization: { name: 'Another Org' }, + }, + ], + }, + }), +})); + +describe('ScopesFilter', () => { + const defaultProps = { + filterButtonText: 'Scopes', + filterValue: [], + setFilter: jest.fn(), + disabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Scopes')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Scopes')).toBeInTheDocument(); + }); + + it('displays filter button text', () => { + renderWrapper(); + expect(screen.getByText('Select Scopes')).toBeInTheDocument(); + }); + + it('handles search input', async () => { + const user = userEvent.setup(); + renderWrapper(); + const searchInputs = screen.queryAllByRole('textbox'); + if (searchInputs.length > 0) { + await user.type(searchInputs[0], 'test search'); + expect(searchInputs[0]).toHaveValue('test search'); + } + }); + + it('calls setFilter when filter changes', () => { + const mockSetFilter = jest.fn(); + renderWrapper(); + expect(screen.getByText('Scopes')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx new file mode 100644 index 00000000..9661438d --- /dev/null +++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx @@ -0,0 +1,54 @@ +import React, { useMemo, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { LocationOn } from '@openedx/paragon/icons'; +import { useScopes } from '@src/authz-module/data/hooks'; +import { DEFAULT_FILTER_PAGE_SIZE } from '@src/authz-module/constants'; +import { MultipleChoiceFilterProps } from './types'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; +import { RESOURCE_ICONS } from '../constants'; +import messages from '../messages'; + +type ScopesFilterProps = Omit; + +const ScopesFilter = ({ + filterButtonText, filterValue, setFilter, disabled, +}: ScopesFilterProps) => { + const { formatMessage } = useIntl(); + const [searchValue, setSearchValue] = useState(undefined); + const { data: scopesData = { results: [] } } = useScopes(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE); + + const filterChoices = useMemo(() => scopesData.results.map((scope) => { + const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + let groupName = formatMessage(messages['authz.team.members.table.group.courses']); + if (scope.externalKey?.startsWith('lib')) { + groupName = formatMessage(messages['authz.team.members.table.group.libraries']); + } + return { + displayName: scope.displayName, + value: scope.externalKey, + description: scope.org?.shortName, + groupName, + groupIcon: scopeIcon, + }; + }), [scopesData, formatMessage]); + + const handleSearchChange = (value: string) => { + setSearchValue(value); + }; + + return ( + + ); +}; + +export default ScopesFilter; diff --git a/src/authz-module/components/TableControlBar/SearchFilter.test.tsx b/src/authz-module/components/TableControlBar/SearchFilter.test.tsx new file mode 100644 index 00000000..f66a7a8e --- /dev/null +++ b/src/authz-module/components/TableControlBar/SearchFilter.test.tsx @@ -0,0 +1,50 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import SearchFilter from './SearchFilter'; + +describe('SearchFilter', () => { + const defaultProps = { + filterValue: '', + setFilter: jest.fn(), + placeholder: 'Search...', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('displays placeholder text', () => { + renderWrapper(); + expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument(); + }); + + it('displays current filter value', () => { + renderWrapper(); + expect(screen.getByDisplayValue('test query')).toBeInTheDocument(); + }); + + it('calls setFilter when input changes', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const input = screen.getByRole('textbox'); + await user.type(input, 'search text'); + expect(mockSetFilter).toHaveBeenCalled(); + }); + + it('handles empty filter value', () => { + renderWrapper(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('handles undefined filter value', () => { + renderWrapper(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); +}); diff --git a/src/authz-module/components/TableControlBar/SearchFilter.tsx b/src/authz-module/components/TableControlBar/SearchFilter.tsx new file mode 100644 index 00000000..b2480f1d --- /dev/null +++ b/src/authz-module/components/TableControlBar/SearchFilter.tsx @@ -0,0 +1,30 @@ +import { + Form, + Icon, +} from '@openedx/paragon'; +import { Search } from '@openedx/paragon/icons'; + +interface SearchFilterProps { + filterValue: string; + setFilter: (value: string) => void; + placeholder: string; +} + +const SearchFilter = ({ + filterValue, setFilter, placeholder, +}: SearchFilterProps) => ( + + } + value={filterValue || ''} + type="text" + onChange={e => { + setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely + }} + placeholder={placeholder} + /> + +); + +export default SearchFilter; diff --git a/src/authz-module/components/TableControlBar/TableControlBar.test.tsx b/src/authz-module/components/TableControlBar/TableControlBar.test.tsx new file mode 100644 index 00000000..a706e15e --- /dev/null +++ b/src/authz-module/components/TableControlBar/TableControlBar.test.tsx @@ -0,0 +1,370 @@ +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import { DataTableContext, TextFilter } from '@openedx/paragon'; +import userEvent from '@testing-library/user-event'; +import TableControlBar from './TableControlBar'; +import RolesFilter from './RolesFilter'; + +const mockSetAllFilters = jest.fn(); +const mockOnFilterChange = jest.fn(); + +const mockColumns = [ + { + id: 'role', + canFilter: true, + Filter: () => null, + setFilter: jest.fn(), + filterOrder: 1, + }, + { + id: 'org', + canFilter: true, + Filter: () => null, + setFilter: jest.fn(), + filterOrder: 2, + }, +]; + +const mockState = { + filters: [ + { id: 'role', value: ['admin'] }, + { id: 'org', value: ['org1'] }, + ], +}; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useOrgs: () => ({ + data: { + count: 0, next: null, previous: null, results: [], + }, + }), + useScopes: () => ({ data: { scopes: [] } }), +})); + +describe('TableControlBar', () => { + const mockDataTableContext = { + columns: mockColumns, + setAllFilters: mockSetAllFilters, + state: mockState, + }; + + const renderWithContext = (component, contextOverride = {}) => { + const context = { ...mockDataTableContext, ...contextOverride }; + return renderWrapper( + + {component} + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSetAllFilters.mockClear(); + mockOnFilterChange.mockClear(); + }); + + it('renders without crashing', () => { + renderWithContext(); + const container = document.querySelector('.authz-table-control-bar'); + expect(container).toBeInTheDocument(); + }); + + it('renders roles filter when configured', async () => { + const user = userEvent.setup(); + const contextWithRolesFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithRolesFilter); + const rolesButton = screen.getByText('Select Roles'); + expect(rolesButton).toBeInTheDocument(); + await user.click(rolesButton); + const superAdminOption = screen.getByRole('checkbox', { name: /Super Admin/i }); + expect(superAdminOption).toBeInTheDocument(); + await user.click(superAdminOption); + expect(contextWithRolesFilter.columns[0].setFilter).toHaveBeenCalled(); + }); + + it('renders search filter when configured', () => { + const contextWithTextFilter = { + columns: [ + { + id: 'search', + Header: 'Search Field', + Filter: TextFilter, + canFilter: true, + filterValue: '', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithTextFilter); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('displays filter chips when filters are applied', () => { + const contextWithAppliedFilters = { + state: { + filters: [ + { id: 'roles', value: ['Admin'] }, + ], + }, + }; + + renderWithContext( + , + contextWithAppliedFilters, + ); + + expect(screen.getByText('Filtered by:')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('shows clear all button when multiple filters applied', async () => { + const user = userEvent.setup(); + const contextWithMultipleFilters = { + state: { + filters: [ + { id: 'roles', value: ['Admin'] }, + { id: 'org', value: ['TestOrg'] }, + ], + }, + }; + + renderWithContext( + , + contextWithMultipleFilters, + ); + const clearButton = screen.getByText('Clear filters'); + expect(clearButton).toBeInTheDocument(); + await user.click(clearButton); + expect(mockSetAllFilters).toHaveBeenCalledWith([]); + expect(screen.queryByText('Clear filters')).not.toBeInTheDocument(); + }); + + it('calls onFilterChange callback when provided', () => { + const contextWithFilters = { + state: { + filters: [{ id: 'test', value: ['value'] }], + }, + }; + + renderWithContext( + , + contextWithFilters, + ); + + expect(mockOnFilterChange).toHaveBeenCalledWith(['test']); + }); + + it('handles empty columns gracefully', () => { + renderWithContext(); + const container = document.querySelector('.authz-table-control-bar'); + expect(container).toBeInTheDocument(); + expect(screen.queryByText('Filter by')).not.toBeInTheDocument(); + }); + it('handles empty columns gracefully', () => { + renderWithContext(); + const container = document.querySelector('.authz-table-control-bar'); + expect(container).toBeInTheDocument(); + expect(screen.queryByText('Filter by')).not.toBeInTheDocument(); + }); + + it('generates keys using column id when available', () => { + const contextWithIdColumn = { + columns: [ + { + id: 'test-id', + accessor: 'test-accessor', + Header: 'Test', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Test Filter', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithIdColumn); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('generates keys using column accessor when id is not available', () => { + const contextWithAccessorColumn = { + columns: [ + { + accessor: 'test-accessor', + Header: 'Test', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Test Filter', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithAccessorColumn); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('handles chronological filter logic for adding new filters', () => { + const mockSetFilter = jest.fn(); + const contextWithFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: mockSetFilter, + }, + ], + }; + + renderWithContext(, contextWithFilter); + + const handleSetFilters = mockSetFilter.mock.calls[0]?.[0]; + if (handleSetFilters) { + const newFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' }; + handleSetFilters(['admin'], newFilter); + } + }); + + it('handles chronological filter logic for removing existing filters', () => { + const mockSetFilter = jest.fn(); + const contextWithFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: mockSetFilter, + }, + ], + }; + + renderWithContext( + , + contextWithFilter, + ); + + const handleSetFilters = mockSetFilter.mock.calls[0]?.[0]; + if (handleSetFilters) { + const existingFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' }; + handleSetFilters([], existingFilter); + } + }); + + it('tests onIconAfterClick functionality directly', () => { + const contextWithAppliedFilters = { + setAllFilters: mockSetAllFilters, + state: { + filters: [ + { id: 'role', value: ['admin', 'user'] }, + { id: 'org', value: ['TestOrg'] }, + ], + }, + }; + + renderWithContext( + , + contextWithAppliedFilters, + ); + const chipElement = screen.getByText('admin').closest('.pgn__chip'); + expect(chipElement).toBeInTheDocument(); + const closeButton = chipElement?.querySelector('button'); + if (closeButton) { + closeButton.click(); + } + expect(mockSetAllFilters).toHaveBeenCalledWith([ + { id: 'role', value: ['user'] }, + { id: 'org', value: ['TestOrg'] }, + ]); + }); + + it('displays warning alert when filter limit is reached', () => { + const maxFilters = Array.from({ length: 10 }, (_, index) => ({ + id: `filter${index}`, + value: [`value${index}`], + })); + + const contextWithMaxFilters = { + setAllFilters: mockSetAllFilters, + state: { + filters: maxFilters, + }, + }; + + renderWithContext( + , + contextWithMaxFilters, + ); + const warningAlert = screen.getByRole('alert'); + expect(warningAlert).toBeInTheDocument(); + expect(warningAlert).toHaveClass('alert-warning'); + }); + + it('manages setChronologicalFilters state correctly when removing filters', () => { + const mockSetFilter = jest.fn(); + const contextWithFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: mockSetFilter, + }, + ], + }; + + renderWithContext( + , + contextWithFilter, + ); + const handleSetFilters = mockSetFilter.mock.calls[0]?.[0]; + if (handleSetFilters) { + const existingFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' }; + handleSetFilters([], existingFilter); + expect(mockSetFilter).toHaveBeenCalled(); + } + }); + + it('removes a filter chip when close icon is clicked', async () => { + renderWithContext(); + const chip = screen.getByText('admin'); + const closeButton = chip.parentElement?.querySelector('button'); + const user = userEvent.setup(); + await user.click(closeButton as HTMLElement); + expect(mockSetAllFilters).toHaveBeenCalledWith([ + { id: 'role', value: [] }, + { id: 'org', value: ['org1'] }, + ]); + }); + + it('removes the correct filter chip for org', async () => { + renderWithContext(); + const chip = screen.getByText('org1'); + const closeButton = chip.parentElement?.querySelector('button'); + const user = userEvent.setup(); + await user.click(closeButton as HTMLElement); + expect(mockSetAllFilters).toHaveBeenCalledWith([ + { id: 'role', value: ['admin'] }, + { id: 'org', value: [] }, + ]); + }); +}); diff --git a/src/authz-module/components/TableControlBar/TableControlBar.tsx b/src/authz-module/components/TableControlBar/TableControlBar.tsx new file mode 100644 index 00000000..3d937c0b --- /dev/null +++ b/src/authz-module/components/TableControlBar/TableControlBar.tsx @@ -0,0 +1,199 @@ +import { useContext, useEffect, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + DataTableContext, + Stack, + TextFilter, + Button, + Chip, + Alert, + Icon, +} from '@openedx/paragon'; +import { + Business, Close, LocationOn, Person, + Warning, +} from '@openedx/paragon/icons'; + +import { MAX_TABLE_FILTERS_APPLIED } from '@src/authz-module/constants'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; +import SearchFilter from './SearchFilter'; +import messages from '../messages'; +import RolesFilter from './RolesFilter'; +import OrgFilter from './OrgFilter'; +import ScopesFilter from './ScopesFilter'; +import { FilterChoice } from './types'; + +const FILTER_CHIPS_ICONS = { + role: Person, + organization: Business, + scope: LocationOn, +}; + +const FILTER_GROUP_TO_ID = { + role: 'role', + organization: 'org', + scope: 'scope', +}; + +interface TableControlBarProps { + onFilterChange?: (filters: string[]) => void; +} + +const TableControlBar = ({ onFilterChange }: TableControlBarProps) => { + const intl = useIntl(); + // applied filters in the order they were selected by the user, to display on the control bar as chips + const [chronologicalFilters, setChronologicalFilters] = useState([]); + const [filtersLimitReached, setFiltersLimitReached] = useState(false); + const { + columns, + setAllFilters, + state, + // @ts-ignore-next-line - Paragon's DataTableContext is not typed + } = useContext(DataTableContext); + + useEffect(() => { + if (state.filters.length > 0) { + const formattedInitialFilters = state.filters.map((filter) => ({ + groupName: filter.id, + value: filter.value[0] || '', + displayName: filter.value[0] || '', + })); + setChronologicalFilters(formattedInitialFilters); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + setFiltersLimitReached(chronologicalFilters.length >= MAX_TABLE_FILTERS_APPLIED); + if (onFilterChange) { + onFilterChange(state.filters.map((filter) => filter.id) || []); + } + }, [chronologicalFilters, onFilterChange, state.filters]); + + const availableFilters = columns.filter((column) => column.canFilter) + .sort((a, b) => (a.filterOrder || 0) - (b.filterOrder || 0)); + + const columnTextFilterHeaders = columns + .filter((column) => column.Filter === TextFilter) + .map((column) => column.Header); + + const getSearchPlaceholder = () => intl.formatMessage(messages['authz.table.controlbar.search.by.fields'], { + firstField: columnTextFilterHeaders[0] || '', + secondField: columnTextFilterHeaders[1] || '', + }); + + const handleCloseFilter = (filterName, filterValue) => { + const actualFilterId = FILTER_GROUP_TO_ID[filterName] || filterName; + const filterGroup = state.filters.find((filter) => filter.id === actualFilterId); + const newFilterValue = filterGroup?.value.filter(item => item !== filterValue) || []; + setAllFilters(state.filters.map(item => ( + item.id !== actualFilterId ? item : { id: item.id, value: newFilterValue }))); + setChronologicalFilters((prevFilters) => prevFilters.filter((filter) => filter.value !== filterValue)); + }; + + const handleSetFilters = (setFilter) => (allFilters: string[], newFilter: FilterChoice) => { + setFilter(allFilters); + setChronologicalFilters((prevFilters) => { + if (!prevFilters.find((filter) => filter.value === newFilter.value)) { + return [...prevFilters, newFilter]; + } + return prevFilters.filter((filter) => filter.value !== newFilter.value); + }); + }; + + const clearAllFilters = () => { + setAllFilters([]); + setChronologicalFilters([]); + }; + return ( +
+ + {availableFilters.map((column) => { + const { Filter } = column; + if (Filter === RolesFilter) { + return ( + + ); + } + if (Filter === OrgFilter) { + return ( + + ); + } + if (Filter === MultipleChoiceFilter) { + return ( + + ); + } + if (Filter === ScopesFilter) { + return ( + filter.id === 'scope')?.value || null} + /> + ); + } + + if (Filter === TextFilter) { + return ( + + ); + } + return null; + })} + + + {chronologicalFilters.length > 0 && ( + + {intl.formatMessage(messages['authz.table.controlbar.filterby.label'])} + + {chronologicalFilters.map((filter) => ( + handleCloseFilter(filter.groupName, filter.value)} + > + {filter.displayName} + + ))} + {chronologicalFilters.length > 1 && ( + + )} + + )} + { filtersLimitReached && ( + + + + {intl.formatMessage(messages['authz.table.controlbar.filters.limit.reached'])} + + + )} + +
+ ); +}; + +export default TableControlBar; diff --git a/src/authz-module/components/TableControlBar/types.ts b/src/authz-module/components/TableControlBar/types.ts new file mode 100644 index 00000000..686f1361 --- /dev/null +++ b/src/authz-module/components/TableControlBar/types.ts @@ -0,0 +1,19 @@ +export type FilterChoice = { + groupName?: string; + groupIcon?: React.ComponentType<{}>; + displayName: string; + value: string; + description?: string; +}; + +export interface MultipleChoiceFilterProps { + filterButtonText: string; + filterChoices: Array; + filterValue: string[] | undefined; + setFilter: (value: string[], newItem: FilterChoice) => void; + isGrouped?: boolean; + isSearchable?: boolean; + onSearchChange?: (value: string) => void; + iconSrc?: React.ComponentType<{}> | undefined; + disabled?: boolean; +} diff --git a/src/authz-module/components/TableFooter/TableFooter.test.tsx b/src/authz-module/components/TableFooter/TableFooter.test.tsx new file mode 100644 index 00000000..b03f4efe --- /dev/null +++ b/src/authz-module/components/TableFooter/TableFooter.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DataTableContext } from '@openedx/paragon'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import Footer from './TableFooter'; + +describe('TableFooter', () => { + const mockGotoPage = jest.fn(); + + const defaultDataTableContext = { + pageCount: 5, + gotoPage: mockGotoPage, + state: { + pageIndex: 0, + pageSize: 10, + }, + itemCount: 42, + rows: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + ], + }; + + const renderFooter = (contextOverrides = {}) => { + const contextValue = { + ...defaultDataTableContext, + ...contextOverrides, + }; + + return renderWrapper( + +