From a82cdaf52a0262ffc530dd98b72b56fe3ff4285d Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 3 Sep 2026 13:12:44 +1000 Subject: [PATCH 01/24] feat: add new team members endpoint --- src/authz-module/data/api.ts | 20 ++++++++++++++------ src/authz-module/data/hooks.ts | 30 +++++++++++++++--------------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 48086962..22a37ace 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -1,6 +1,5 @@ import { - Org, Scope, - UserRole, + Org, Scope, TeamMember, UserRole, } from '@src/types'; import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; import { getApiUrl } from '@src/data/utils'; @@ -52,8 +51,8 @@ export interface AssignTeamMembersRoleRequest { scopes: string[]; } -export interface GetAllRoleAssignmentsResponse { - results: UserRole[]; +export interface GetTeamMembersAssignmentsResponse { + results: TeamMember[]; count: number; next: string | null; previous: string | null; @@ -141,8 +140,16 @@ export const revokeUserRoles = async ( return camelCaseObject(res.data); }; -export const getAllRoleAssignments = async (querySettings: QuerySettings): Promise => { - const url = new URL(getApiUrl('/api/authz/v1/assignments/')); +/** + * Fetches team members grouped by user: one entry per user carrying up to + * `assignmentsLimit` of their role assignments plus their absolute `assignmentCount`. + * + * Filters decide which users come back; they do not trim each user's nested + * `assignments` array, and `assignmentCount` always reflects the user's full total. + */ +export const getTeamMembersAssignments = async (querySettings: QuerySettings, assignmentsLimit: number) +: Promise => { + const url = new URL(getApiUrl('/api/authz/v1/users/')); if (querySettings.roles) { url.searchParams.set('roles', querySettings.roles); @@ -160,6 +167,7 @@ export const getAllRoleAssignments = async (querySettings: QuerySettings): Promi url.searchParams.set('sort_by', querySettings.sortBy); url.searchParams.set('order', querySettings.order); } + url.searchParams.set('assignments_limit', assignmentsLimit.toString()); url.searchParams.set('page_size', querySettings.pageSize.toString()); url.searchParams.set('page', (querySettings.pageIndex + 1).toString()); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 947c2a08..db9e0d2e 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -3,8 +3,8 @@ import { } from '@tanstack/react-query'; import { appId } from '@src/constants'; import { - assignTeamMembersRole, AssignTeamMembersRoleRequest, getAllRoleAssignments, - GetAllRoleAssignmentsResponse, getOrgs, GetOrgsResponse, + assignTeamMembersRole, AssignTeamMembersRoleRequest, getTeamMembersAssignments, + GetTeamMembersAssignmentsResponse, getOrgs, GetOrgsResponse, getScopes, GetScopesResponse, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, getUserAssignedRoles, GetUserAssignmentsResponse, validateUsers, ValidateUsersRequest, GetScopesParams, @@ -13,7 +13,7 @@ import { const authzQueryKeys = { all: [appId, 'authz'] as const, - allRoleAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'allRoleAssignments', querySettings] as const, + teamMembersAssignments: (querySettings?: QuerySettings, assignmentsLimit?: number) => [...authzQueryKeys.all, 'teamMembersAssignments', querySettings, assignmentsLimit] as const, orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const, scopes: (params?: Omit) => [...authzQueryKeys.all, 'scopes', params] as const, userRoles: (username?: string, querySettings?: QuerySettings) => [...authzQueryKeys.all, 'userRoles', username, querySettings] as const, @@ -38,7 +38,7 @@ export const useAssignTeamMembersRole = () => { if (!error) { queryClient.invalidateQueries({ queryKey: [...authzQueryKeys.all, 'userRoles'] }); queryClient.invalidateQueries({ - predicate: (query) => query.queryKey.includes('allRoleAssignments'), + predicate: (query) => query.queryKey.includes('teamMembersAssignments'), }); } }, @@ -77,28 +77,28 @@ export const useRevokeUserRoles = () => { predicate: (query) => query.queryKey.includes('userRoles'), }); queryClient.invalidateQueries({ - predicate: (query) => query.queryKey.includes('allRoleAssignments'), + predicate: (query) => query.queryKey.includes('teamMembersAssignments'), }); }, }); }; /** - * React Query hook to fetch all role assignments across scopes and roles, - * with support for filtering, sorting, and pagination. - * It retrieves a comprehensive list of user-role assignments based - * on the provided query settings. + * React Query hook to fetch team members grouped by user, with support for + * filtering, sorting, and pagination. Each result is one user carrying up to + * `assignmentsLimit` of their role assignments plus their absolute total. * - * @param querySettings - Optional parameters for filtering by roles, scopes, + * @param querySettings - Parameters for filtering by roles, scopes, * organizations, search term, sorting, and pagination. + * @param assignmentsLimit - Maximum assignments to nest under each user. * * @example - * const { data: roleAssignments } = useAllRoleAssignments({ roles: 'editor', pageSize: 20 }); + * const { data: teamMembers } = useTeamMembersAssignments({ roles: 'editor', pageSize: 20 }, 3); */ -export const useAllRoleAssignments = (querySettings: QuerySettings) => { - const result = useQuery({ - queryKey: authzQueryKeys.allRoleAssignments(querySettings), - queryFn: () => getAllRoleAssignments(querySettings), +export const useTeamMembersAssignments = (querySettings: QuerySettings, assignmentsLimit: number) => { + const result = useQuery({ + queryKey: authzQueryKeys.teamMembersAssignments(querySettings, assignmentsLimit), + queryFn: () => getTeamMembersAssignments(querySettings, assignmentsLimit), staleTime: 1000 * 60 * 30, // refetch after 30 minutes retry: false, refetchOnWindowFocus: false, From 0ac9478bf62471a83e624cd016075c462ebc4f3c Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 3 Sep 2026 15:21:16 +1000 Subject: [PATCH 02/24] refactor: make team meambers table roles stacked --- src/authz-module/authz-home/index.test.tsx | 16 +- .../components/TableCells.test.tsx | 118 ------ src/authz-module/components/TableCells.tsx | 74 +--- .../TableControlBar/TableControlBar.tsx | 9 +- .../components/TableFooter/TableFooter.tsx | 15 +- src/authz-module/constants.ts | 13 +- src/authz-module/data/hooks.test.tsx | 43 ++- src/authz-module/index.scss | 95 ++++- .../roles-permissions/course/constants.ts | 26 +- .../roles-permissions/library/constants.ts | 6 +- .../team-members/AssignedRolesCell.tsx | 79 ++++ .../team-members/CollapseRowsOnChange.tsx | 39 ++ .../team-members/MoreRolesToggle.tsx | 65 ++++ .../TeamMemberViewActionCell.test.tsx | 111 ++++++ .../team-members/TeamMemberViewActionCell.tsx | 67 ++++ .../team-members/TeamMembersTable.test.tsx | 349 ++++++++++++++---- .../team-members/TeamMembersTable.tsx | 191 ++++++---- .../team-members/UserAssignmentsSubTable.tsx | 122 ++++++ src/authz-module/team-members/messages.ts | 55 ++- src/authz-module/utils.tsx | 13 + src/types.ts | 26 ++ 21 files changed, 1160 insertions(+), 372 deletions(-) create mode 100644 src/authz-module/team-members/AssignedRolesCell.tsx create mode 100644 src/authz-module/team-members/CollapseRowsOnChange.tsx create mode 100644 src/authz-module/team-members/MoreRolesToggle.tsx create mode 100644 src/authz-module/team-members/TeamMemberViewActionCell.test.tsx create mode 100644 src/authz-module/team-members/TeamMemberViewActionCell.tsx create mode 100644 src/authz-module/team-members/UserAssignmentsSubTable.tsx diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index ffb4841f..4d486c4a 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -1,5 +1,5 @@ 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'; @@ -15,7 +15,7 @@ jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({ })); jest.mock('@src/authz-module/data/hooks', () => ({ - useAllRoleAssignments: jest.fn(), + useTeamMembersAssignments: jest.fn(), useOrgs: jest.fn(), useScopes: jest.fn(), })); @@ -46,7 +46,7 @@ const renderAuthzHome = () => renderWithAllProviders( describe('AuthzHome', () => { beforeEach(() => { - (useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse); + (useTeamMembersAssignments as jest.Mock).mockReturnValue(emptyResponse); (useOrgs as jest.Mock).mockReturnValue(emptyResponse); (useScopes as jest.Mock).mockReturnValue(emptyScopesResponse); }); @@ -80,11 +80,13 @@ describe('AuthzHome', () => { 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(); }); }); diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index fbf116b9..12ef9c13 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event'; import { DataTableContext } from '@openedx/paragon'; import { NameCell, - ViewActionCell, RoleCell, OrgCell, ScopeCell, @@ -139,123 +138,6 @@ describe('TableCells Components', () => { }); }); - 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: { - id: '0', - original: mockUserRole, - }, - }; - beforeEach(() => { - initializeMocks(); - 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('/admin-console/authz/user/johndoe'); - }); - - it('navigates with correct username for different user', async () => { - const user = userEvent.setup(); - const differentUserProps = { - row: { - id: '0', - original: { - ...mockUserRole, - username: 'janedoe', - }, - }, - }; - - renderWrapper(); - - const viewButton = screen.getByRole('button', { name: /view/i }); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/janedoe'); - }); - - it('handles empty username gracefully', async () => { - const user = userEvent.setup(); - const emptyUsernameProps = { - row: { - id: '0', - original: { - ...mockUserRole, - username: '', - }, - }, - }; - - renderWrapper(); - - const viewButton = screen.getByRole('button', { name: /view/i }); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/'); - }); - - it('handles special characters in username', async () => { - const user = userEvent.setup(); - const specialUsernameProps = { - row: { - id: '0', - original: { - ...mockUserRole, - username: 'user+with@special.chars', - }, - }, - }; - - renderWrapper(); - - const viewButton = screen.getByRole('button', { name: /view/i }); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/user%2Bwith%40special.chars'); - }); - - it('disables the view action and shows a tooltip when course authoring is disabled for the course', async () => { - const user = userEvent.setup(); - renderWrapper( false} />); - - const viewButton = screen.getByRole('button', { name: /view/i }); - expect(viewButton).toBeDisabled(); - - await user.hover(viewButton); - expect(screen.getByText(/manage its team in Studio instead/i)).toBeInTheDocument(); - }); - }); - describe('RoleCell', () => { const mockCell = { getCellProps: jest.fn(() => ({ 'data-testid': 'role-cell' })), diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index f1df74d1..1eb79e30 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -1,27 +1,21 @@ import { SiteContext, useIntl } from '@openedx/frontend-base'; import { - RemoveRedEye, Delete, ExpandMore, Info, } from '@openedx/paragon/icons'; import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; -import { useNavigate } from 'react-router-dom'; import { useContext, useMemo, type ComponentProps } from 'react'; import { - ADMIN_ROLES, buildAuditUserPath, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL, + ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, type DataTableCellProps, } from '@openedx/paragon'; -import { RESOURCE_ICONS } from './constants'; +import { getScopeResourceIcon } from '@src/authz-module/utils'; import messages from './messages'; import ViewMoreLink from './ViewMoreLink'; -interface ViewActionCellExtraProps { - isCourseEnabled: (scope: string) => boolean; -} - interface DataTableInstance { state?: { expanded?: Record; @@ -51,7 +45,7 @@ type DisabledCourseActionButtonProps = Pick, ' // A disabled button can't trigger its own tooltip (Paragon sets pointer-events: none on it), // so the OverlayTrigger must live on a wrapper element that still receives hover events. -const DisabledCourseActionButton = ({ +export const DisabledCourseActionButton = ({ src, alt, size, variant, }: DisabledCourseActionButtonProps) => { const { formatMessage } = useIntl(); @@ -81,48 +75,22 @@ const NameCell = ({ row }: CellProps) => { const intl = useIntl(); const { authenticatedUser } = useContext(SiteContext); const username = authenticatedUser?.username; + const displayName = row.original.fullName || row.original.username || ''; if (row.original.username === username) { return ( - - {row.original.fullName || row.original.username} + + {displayName} {intl.formatMessage(messages['authz.table.username.current'])} ); } - return {row.original.fullName || row.original.username || ''}; -}; - -const ViewActionCell = ({ row, isCourseEnabled }: CellProps & Partial) => { - const { formatMessage } = useIntl(); - const navigate = useNavigate(); - const viewPath = buildAuditUserPath(row.original.username ?? ''); - const isCourseScope = !row.original.role?.startsWith('lib') && !DJANGO_MANAGED_ROLES.includes(row.original.role); - const isDisabled = isCourseEnabled !== undefined && isCourseScope && !isCourseEnabled(row.original.scope); - - if (isDisabled) { - return ( - - ); - } - - return ( - navigate(viewPath)} - /> - ); + return {displayName}; }; -const createViewActionCell = (extraProps: ViewActionCellExtraProps) => function customViewActionCell(cellProps) { - return ; -}; +const EmailCell = ({ value }: CellPropsWithValue) => ( + {value} +); const OrgCell = ({ value, row }: CellPropsWithValue) => { const { formatMessage } = useIntl(); @@ -136,19 +104,12 @@ const OrgCell = ({ value, row }: CellPropsWithValue) => { 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]); + const { scopeText, iconSrc } = useMemo(() => ({ + scopeText: DJANGO_MANAGED_ROLES.includes(row.original.role) + ? formatMessage(messages['authz.user.table.scope.global.label']) + : row.original.scope, + iconSrc: getScopeResourceIcon(row.original.role), + }), [row.original.role, row.original.scope, formatMessage]); return ( @@ -297,12 +258,11 @@ const createActionsCell = (extraProps: ActionsCellExtraProps) => function custom export { NameCell, - ViewActionCell, + EmailCell, RoleCell, OrgCell, ScopeCell, PermissionsCell, ViewAllPermissionsCell, createActionsCell, - createViewActionCell, }; diff --git a/src/authz-module/components/TableControlBar/TableControlBar.tsx b/src/authz-module/components/TableControlBar/TableControlBar.tsx index 1b09acec..677a37b9 100644 --- a/src/authz-module/components/TableControlBar/TableControlBar.tsx +++ b/src/authz-module/components/TableControlBar/TableControlBar.tsx @@ -1,4 +1,6 @@ -import { useContext, useEffect, useState } from 'react'; +import { + useContext, useEffect, useState, type ReactNode, +} from 'react'; import { useIntl } from '@openedx/frontend-base'; import { DataTableContext, @@ -37,9 +39,11 @@ const FILTER_GROUP_TO_ID = { interface TableControlBarProps { onFilterChange?: (filters: string[]) => void; + /** Optional count rendered right-aligned on the filter row, e.g. "Showing 10 users of 100." */ + countLabel?: ReactNode; } -const TableControlBar = ({ onFilterChange }: TableControlBarProps) => { +const TableControlBar = ({ onFilterChange, countLabel }: 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([]); @@ -164,6 +168,7 @@ const TableControlBar = ({ onFilterChange }: TableControlBarProps) => { } return null; })} + {countLabel && {countLabel}} {chronologicalFilters.length > 0 && ( diff --git a/src/authz-module/components/TableFooter/TableFooter.tsx b/src/authz-module/components/TableFooter/TableFooter.tsx index 04a74d11..8845e524 100644 --- a/src/authz-module/components/TableFooter/TableFooter.tsx +++ b/src/authz-module/components/TableFooter/TableFooter.tsx @@ -3,7 +3,18 @@ import { useIntl } from '@openedx/frontend-base'; import { DataTableContext, Pagination, TableFooter } from '@openedx/paragon'; import messages from '@src/authz-module/components/messages'; -const Footer = () => { +const defaultShowingMessage = messages['authz.table.footer.items.showing.text']; + +interface FooterProps { + /** + * Overrides the "Showing X of Y." text — used by tables whose rows are not + * assignments (e.g. the team members table counts users). Receives `pageSize` + * and `itemCount`. + */ + showingMessage?: typeof defaultShowingMessage; +} + +const Footer = ({ showingMessage }: FooterProps) => { const { formatMessage } = useIntl(); const { pageCount, gotoPage, state, itemCount, rows, @@ -13,7 +24,7 @@ const Footer = () => { return ( - {formatMessage(messages['authz.table.footer.items.showing.text'], { pageSize: rows.length, itemCount })} + {formatMessage(showingMessage ?? defaultShowingMessage, { pageSize: rows.length, itemCount })} ({ - username: 'skeleton', - name: '', - email: '', - roles: [], -})); /** Absolute base path of the module; src/routes.tsx derives its route prefix from this. */ export const AUTHZ_HOME_PATH = '/admin-console/authz'; @@ -85,5 +79,12 @@ export const DJANGO_MANAGED_ROLES = ['django.superuser', 'django.globalstaff']; export const TABLE_DEFAULT_PAGE_SIZE = 10; +/** + * Maximum assignments nested under each user in the team members table. Sent as the + * `assignments_limit` query param and used as the numerator of the sub-table's + * "Showing 03 of 10" footer; the denominator is the user's absolute `assignmentCount`. + */ +export const MAX_INLINE_ASSIGNMENTS = 3; + export const DEFAULT_FILTER_PAGE_SIZE = 5; export const ADMIN_ROLES = ['course_admin', 'library_admin']; diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index b6fc2d5c..c9d14062 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -6,7 +6,7 @@ import type { QuerySettings } from './api'; import { useAssignTeamMembersRole, useRevokeUserRoles, - useAllRoleAssignments, + useTeamMembersAssignments, useOrgs, useScopes, useUserAssignedRoles, @@ -31,14 +31,19 @@ jest.mock('@src/constants', () => ({ const mockAssignments = { results: [ { - isSuperadmin: false, - role: 'course_staff', - org: 'OpenedX', - scope: 'course-v1:OpenedX+DemoX+DemoCourse', - permissionCount: 27, - fullName: 'John Doe', username: 'johndoe', + fullName: 'John Doe', email: 'johndoe@example.com', + assignmentCount: 5, + assignments: [ + { + isSuperadmin: false, + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 27, + }, + ], }, ], count: 1, @@ -510,19 +515,19 @@ describe('useRevokeUserRoles', () => { if (userRolesCall) { const { predicate } = userRolesCall; expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(true); - expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembers'] })).toBe(false); + expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembersAssignments'] })).toBe(false); } - const allRoleAssignmentsCall = predicateCalls.find(call => { + const teamMembersAssignmentsCall = predicateCalls.find(call => { const { predicate } = call; - return predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments', {}] }); + return predicate({ queryKey: ['test-app', 'authz', 'teamMembersAssignments', {}] }); }); - expect(allRoleAssignmentsCall).toBeDefined(); + expect(teamMembersAssignmentsCall).toBeDefined(); - if (allRoleAssignmentsCall) { - const { predicate } = allRoleAssignmentsCall; - expect(predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments'] })).toBe(true); + if (teamMembersAssignmentsCall) { + const { predicate } = teamMembersAssignmentsCall; + expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembersAssignments'] })).toBe(true); expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(false); } @@ -530,21 +535,23 @@ describe('useRevokeUserRoles', () => { }); }); -describe('useAllRoleAssignments', () => { +describe('useTeamMembersAssignments', () => { beforeEach(() => { mockHttpClient().mockReturnValue({ get: jest.fn(() => Promise.resolve({ data: mockAssignments })), }); }); - it('fetches and returns role assignments', async () => { + it('fetches and returns team members with their nested assignments', async () => { const { result } = renderHook( - () => useAllRoleAssignments(mockQuerySettings), + () => useTeamMembersAssignments(mockQuerySettings, 3), { wrapper: createWrapper() }, ); await waitFor(() => { expect(result.current.data?.results).toHaveLength(1); expect(result.current.data?.results[0].username).toBe('johndoe'); + expect(result.current.data?.results[0].assignmentCount).toBe(5); + expect(result.current.data?.results[0].assignments).toHaveLength(1); expect(result.current.data?.count).toBe(1); }); }); @@ -558,7 +565,7 @@ describe('useAllRoleAssignments', () => { })), }); const { result } = renderHook( - () => useAllRoleAssignments(mockQuerySettings), + () => useTeamMembersAssignments(mockQuerySettings, 3), { wrapper: createWrapper() }, ); await waitFor(() => { diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 856af828..344dd78c 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -73,6 +73,100 @@ max-height: 80vh; } } + /* + Team members table truncation. The ellipsis itself comes from Paragon's `.text-truncate` + utility; these rules only supply what Paragon has no utility for — the `min-width: 0` + that lets a flex child shrink enough to clip, and the max-widths that place the cut near + the character counts in the design. + */ + .authz-cell-username { max-width: 15rem; } // ~24 characters + .authz-cell-email { max-width: 17rem; } // ~28 characters + + /* + Column sizing for the team members table. Auto-layout hands width to the short + Username/Email values and starves "Assigned roles", cutting the scope name off far + earlier than the design allows, so the columns are pinned explicitly. Widths follow + the design's character budget: ~24 for username, ~28 for email, the bulk to the role + summary, and just enough for the toggle and the action icon. + */ + .team-members-table { + .pgn__data-table { + table-layout: fixed; + } + + th:nth-child(1) { width: 16%; } // Username, ~24 characters + th:nth-child(2) { width: 18%; } // Email, ~28 characters + th:nth-child(3) { width: 46%; } // Assigned roles: badge + scope + org subline + th:nth-child(4) { width: 13%; } // "+N more roles" toggle + th:nth-child(5) { width: 7%; } // Actions + + // The toggle reads as one phrase; wrapping it mid-label looks broken. + td:nth-child(4) { + white-space: nowrap; + } + + td { + overflow: hidden; + } + + // The breakdown sizes its own three columns; it must not inherit the widths above. + .team-members-table__subtable { + .pgn__data-table { + table-layout: auto; + } + + th:nth-child(1), + th:nth-child(2), + th:nth-child(3) { + width: auto; + } + } + } + + .team-members-table__subtable { + // The breakdown sits inside a padded container of its own, so the module's blanket + // 1rem on every data-table wrapper would double it up. + .pgn__data-table-wrapper { + padding: 0; + } + + /* + Paragon stripes rows with `.pgn__data-table.is-striped tr:nth-child(2n)` — a + descendant selector, so the striped outer table reaches into this nested one and + `isStriped={false}` on the inner table cannot override it. Reset it here, scoped to + a nested table that actually opted out, so an inner table that wants stripes keeps them. + */ + .pgn__data-table:not(.is-striped) tr { + background-color: transparent; + + // With the stripes gone, rows need an explicit divider to stay readable. + &:not(:last-child) td { + border-bottom: var(--pgn-size-border-width) solid var(--pgn-color-border); + } + } + } + + /* + Badge labels vary in length ("Course Admin" vs "Library Contributor"), so without a + floor the "In" connector and the scope start at a different x on every row. Sized to + the widest label so the three parts line up down the column. + */ + .authz-assigned-roles .authz-role-badge { + min-width: 11rem; + } + + .authz-scope-cell { + min-width: 0; + max-width: 25rem; // org subline, ~40 characters + + &__name { + max-width: 22rem; // ~35 characters + } + + &__org { + line-height: 1.2; + } + } @media(--pgn-size-breakpoint-max-width-sm){ .flex-column-sm { @@ -132,4 +226,3 @@ .scope-list { max-height: 500px; } - diff --git a/src/authz-module/roles-permissions/course/constants.ts b/src/authz-module/roles-permissions/course/constants.ts index 13d0be97..0a288c0d 100644 --- a/src/authz-module/roles-permissions/course/constants.ts +++ b/src/authz-module/roles-permissions/course/constants.ts @@ -10,7 +10,7 @@ import { Download, DrawShapes, CheckCircle, - RemoveRedEye, + Visibility, Plus, EditOutline, DownloadDone, @@ -148,7 +148,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_access_content', description: 'See the course in the Studio home and access the course outline in read-only mode. Includes the "View Live" option to preview the course as a learner in the LMS.', label: 'View course', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE, @@ -176,7 +176,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_library_updates', description: 'View pending updates from content libraries linked to this course.', label: 'View library updates', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_LIBRARY_UPDATES, @@ -191,7 +191,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_updates_handouts', description: 'See course announcements and handouts visible to learners.', label: 'View course updates', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, @@ -206,7 +206,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_pages_resources', description: 'See the Pages & Resources section in Studio.', label: 'View pages & resources', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, @@ -221,7 +221,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_files', description: 'See the list of files and assets uploaded to the course.', label: 'View files', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, @@ -250,7 +250,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_schedule_details', description: 'See the course schedule (start and end dates, enrollment dates, and pacing settings) and course details (summary, pacing, and prerequisites).', label: 'View schedule & details', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, @@ -272,7 +272,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_grading', description: 'See the grading configuration for the course, including assignment types and grading scale.', label: 'View grading settings', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, @@ -287,7 +287,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_team_group', description: 'See the list of users with a role assigned to this course.', label: 'View course team', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, @@ -301,7 +301,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_team_group', description: 'See the list of content groups and their configurations for this course.', label: 'View group configurations', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, @@ -323,7 +323,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_advanced_certificates', description: 'Access the Advanced Settings page in Studio. This covers a wide range of technical course configurations, including proctoring, timed exams, LTI tools, enrollment limits, and custom display options.', label: 'View advanced settings', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, @@ -337,7 +337,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_advanced_certificates', description: 'See the course certificate settings.', label: 'View certificates', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, @@ -374,7 +374,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_other', description: 'See the course launch checklist in Studio.', label: 'View checklists', - icon: RemoveRedEye, + icon: Visibility, }, ]; diff --git a/src/authz-module/roles-permissions/library/constants.ts b/src/authz-module/roles-permissions/library/constants.ts index dc15f162..adfb7404 100644 --- a/src/authz-module/roles-permissions/library/constants.ts +++ b/src/authz-module/roles-permissions/library/constants.ts @@ -3,7 +3,7 @@ import { } from '@src/types'; import { Group, CollectionsBookmark, Notes, AutoAwesomeMosaic, - RemoveRedEye, + Visibility, Settings, DownloadDone, Plus, @@ -91,7 +91,7 @@ export const libraryPermissions: PermissionMetadata[] = [ resource: 'library', label: 'View', description: 'See the library in Studio and access its content in read-only mode.', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, @@ -133,7 +133,7 @@ export const libraryPermissions: PermissionMetadata[] = [ resource: 'library_team', label: 'View', description: 'See the list of users with a role assigned to this library.', - icon: RemoveRedEye, + icon: Visibility, }, { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, diff --git a/src/authz-module/team-members/AssignedRolesCell.tsx b/src/authz-module/team-members/AssignedRolesCell.tsx new file mode 100644 index 00000000..a856fb0f --- /dev/null +++ b/src/authz-module/team-members/AssignedRolesCell.tsx @@ -0,0 +1,79 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@openedx/paragon'; +import { Business, Person } from '@openedx/paragon/icons'; +import { DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants'; +import { getScopeResourceIcon } from '@src/authz-module/utils'; +import componentMessages from '@src/authz-module/components/messages'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import messages from './messages'; + +interface AssignedRolesCellProps { + row: { original: TeamMember }; +} + +interface AssignmentSummaryProps { + assignment: TeamMemberAssignment; +} + +/** The role pill: a light rounded block with the person icon, not a Paragon Chip. */ +export const RoleBadge = ({ role }: { role: string }) => ( +
+ + {MAP_ROLE_KEY_TO_LABEL[role] || role} +
+); + +/** + * Renders one assignment as "[Role] In " with the organization on a subline. + * Shared by the collapsed row and the nested sub-table so both read identically. + */ +export const AssignmentSummary = ({ assignment }: AssignmentSummaryProps) => { + const { formatMessage } = useIntl(); + const { + role, scope, scopeName, org, + } = assignment; + const isDjangoRole = DJANGO_MANAGED_ROLES.includes(role); + + const scopeText = isDjangoRole + ? formatMessage(componentMessages['authz.user.table.scope.global.label']) + : scopeName || scope; + const orgText = isDjangoRole + ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) + : org; + + return ( +
+ + + {formatMessage(messages['authz.team.members.table.assigned.roles.connector'])} + +
+ + + {scopeText} + + + + {orgText} + +
+
+ ); +}; + +/** + * Collapsed-row cell. Renders `assignments[0]` exactly as the API returns it — the + * sub-table's first row must match it, so no client-side reordering happens here. The + * badge stays visible while the row is expanded, for visual continuity. + */ +const AssignedRolesCell = ({ row }: AssignedRolesCellProps) => { + const [firstAssignment] = row.original.assignments ?? []; + + if (!firstAssignment) { + return null; + } + + return ; +}; + +export default AssignedRolesCell; diff --git a/src/authz-module/team-members/CollapseRowsOnChange.tsx b/src/authz-module/team-members/CollapseRowsOnChange.tsx new file mode 100644 index 00000000..1fa89679 --- /dev/null +++ b/src/authz-module/team-members/CollapseRowsOnChange.tsx @@ -0,0 +1,39 @@ +import { useContext, useEffect, useRef } from 'react'; +import { DataTableContext } from '@openedx/paragon'; + +interface DataTableInstance { + state: { + pageIndex: number; + filters: Array<{ id: string; value: unknown }>; + }; + toggleAllRowsExpanded?: (expanded: boolean) => void; +} + +/** + * Collapses every expanded row whenever the page changes or a filter is applied or + * removed, so a stale breakdown never stays open over rows it no longer belongs to. + * + * Renders nothing — it exists to read `DataTableContext`, so it must sit inside + * ``. + */ +const CollapseRowsOnChange = () => { + const { state, toggleAllRowsExpanded } = useContext(DataTableContext) as DataTableInstance; + const { pageIndex, filters } = state; + // Serialized so the effect compares filter contents rather than array identity, which + // react-table replaces on every render. + const serializedFilters = JSON.stringify(filters); + const isFirstRender = useRef(true); + + useEffect(() => { + // Nothing is expanded on mount; collapsing here would fight the initial state. + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + toggleAllRowsExpanded?.(false); + }, [pageIndex, serializedFilters, toggleAllRowsExpanded]); + + return null; +}; + +export default CollapseRowsOnChange; diff --git a/src/authz-module/team-members/MoreRolesToggle.tsx b/src/authz-module/team-members/MoreRolesToggle.tsx new file mode 100644 index 00000000..c627f381 --- /dev/null +++ b/src/authz-module/team-members/MoreRolesToggle.tsx @@ -0,0 +1,65 @@ +import { useContext } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTableContext } from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; +import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; +import type { TeamMember } from '@src/types'; +import messages from './messages'; + +interface DataTableInstance { + state?: { + expanded?: Record; + }; + toggleRowExpanded?: (rowId: string, expanded: boolean) => void; +} + +interface MoreRolesToggleProps { + row: { + id: string; + isExpanded?: boolean; + original: TeamMember; + toggleRowExpanded?: () => void; + }; +} + +/** + * Expands a team member row to reveal their full role breakdown. + * + * The count is `assignmentCount - 1`: the collapsed row already shows the first role, so + * only the remaining ones are "more". Renders nothing when the user has a single role, + * since there is nothing further to reveal. + */ +const MoreRolesToggle = ({ row }: MoreRolesToggleProps) => { + const { formatMessage } = useIntl(); + const instance = useContext(DataTableContext) as DataTableInstance; + const { assignmentCount } = row.original; + + if (!assignmentCount || assignmentCount <= 1) { + return null; + } + + const handleToggleExpanded = () => { + if (!row.isExpanded && instance) { + // Close any other expanded row first, so only one breakdown is open at a time. + const expanded = instance.state?.expanded || {}; + Object.keys(expanded).forEach((rowId) => { + if (rowId !== row.id && expanded[rowId]) { + instance.toggleRowExpanded?.(rowId, false); + } + }); + } + row.toggleRowExpanded?.(); + }; + + return ( + + ); +}; + +export default MoreRolesToggle; diff --git a/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx b/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx new file mode 100644 index 00000000..2b741acd --- /dev/null +++ b/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx @@ -0,0 +1,111 @@ +import { screen } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import type { TeamMember } from '@src/types'; +import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; +import TeamMemberViewActionCell from './TeamMemberViewActionCell'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const courseAssignment = { + isSuperadmin: false, + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 27, +}; + +const libraryAssignment = { + isSuperadmin: false, + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + permissionCount: 11, +}; + +const teamMember: TeamMember = { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: 1, + assignments: [courseAssignment], +}; + +const cellPropsFor = (overrides: Partial = {}) => ({ + row: { original: { ...teamMember, ...overrides } }, +}); + +describe('TeamMemberViewActionCell', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'testuser@example.com', + }, + }); + mockNavigate.mockClear(); + }); + + it('renders an accessible view action button', () => { + renderWrapper(); + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toBeInTheDocument(); + expect(viewButton).toHaveAttribute('aria-label'); + }); + + it('navigates to the audit page for the row user', async () => { + const user = userEvent.setup(); + renderWrapper(); + + await user.click(screen.getByRole('button', { name: /view/i })); + + expect(mockNavigate).toHaveBeenCalledWith(`${AUTHZ_HOME_PATH}/user/johndoe`); + }); + + it('handles special characters in the username', async () => { + const user = userEvent.setup(); + renderWrapper(); + + await user.click(screen.getByRole('button', { name: /view/i })); + + expect(mockNavigate).toHaveBeenCalledWith(`${AUTHZ_HOME_PATH}/user/user%2Bwith%40special.chars`); + }); + + it('stays enabled when at least one assignment is viewable', () => { + renderWrapper( + false} + />, + ); + expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); + }); + + it('disables the action with a tooltip when every assignment sits in a disabled course', async () => { + const user = userEvent.setup(); + renderWrapper( false} />); + + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toBeDisabled(); + + await user.hover(viewButton); + expect(screen.getByText(/manage its team in Studio instead/i)).toBeInTheDocument(); + }); + + it('never calls the flag check with a missing scope', () => { + const isCourseEnabled = jest.fn(() => true); + renderWrapper( + , + ); + + expect(isCourseEnabled).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); + }); +}); diff --git a/src/authz-module/team-members/TeamMemberViewActionCell.tsx b/src/authz-module/team-members/TeamMemberViewActionCell.tsx new file mode 100644 index 00000000..9a34d190 --- /dev/null +++ b/src/authz-module/team-members/TeamMemberViewActionCell.tsx @@ -0,0 +1,67 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { IconButton } from '@openedx/paragon'; +import { Visibility } from '@openedx/paragon/icons'; +import { useNavigate } from 'react-router-dom'; +import { buildAuditUserPath, DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; +import { DisabledCourseActionButton } from '@src/authz-module/components/TableCells'; +import componentMessages from '@src/authz-module/components/messages'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; + +interface TeamMemberViewActionCellProps { + row: { original: TeamMember }; + isCourseEnabled?: (scope: string) => boolean; +} + +const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled?: (scope: string) => boolean) => { + const isCourseScope = !assignment.role?.startsWith('lib') + && !DJANGO_MANAGED_ROLES.includes(assignment.role); + if (!isCourseScope || isCourseEnabled === undefined) { + return true; + } + return isCourseEnabled(assignment.scope); +}; + +/** + * Opens a team member's audit page. + * + * Rows are users rather than single assignments, so the course-authoring flag is evaluated + * across the user's assignments: the action stays enabled while at least one of them is + * viewable, and is only disabled when every one sits in a course that hasn't moved to the + * new roles experience. Note `assignments` is capped by `assignments_limit`, so this reads + * the assignments actually returned, not necessarily the user's full set. + */ +const TeamMemberViewActionCell = ({ row, isCourseEnabled }: TeamMemberViewActionCellProps) => { + const { formatMessage } = useIntl(); + const navigate = useNavigate(); + const { assignments = [], username } = row.original; + + const hasViewableAssignment = assignments.length === 0 + || assignments.some((assignment) => isViewable(assignment, isCourseEnabled)); + + if (!hasViewableAssignment) { + return ( + + ); + } + + return ( + navigate(buildAuditUserPath(username))} + /> + ); +}; + +export const createTeamMemberViewActionCell = ( + extraProps: { isCourseEnabled: (scope: string) => boolean }, +) => function customTeamMemberViewActionCell(cellProps) { + return ; +}; + +export default TeamMemberViewActionCell; diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index c9ba6ca4..3a376f89 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -1,11 +1,12 @@ -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithAllProviders } from '@src/testUtils'; -import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; -import type { GetAllRoleAssignmentsResponse } from '@src/authz-module/data/api'; +import { useTeamMembersAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; +import type { GetTeamMembersAssignmentsResponse } from '@src/authz-module/data/api'; import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions'; +import { AUTHZ_HOME_PATH, MAX_INLINE_ASSIGNMENTS } from '@src/authz-module/constants'; import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; import TeamMembersTable from './TeamMembersTable'; @@ -15,8 +16,17 @@ jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ const mockUseViewTeamPermissions = useViewTeamPermissions as jest.Mock; -const mockedAllRoleAssignments: { - data: GetAllRoleAssignmentsResponse | undefined; +const courseAssignment = { + isSuperadmin: false, + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeName: 'Open edX Demo Course', + permissionCount: 27, +}; + +const mockedTeamMembers: { + data: GetTeamMembersAssignmentsResponse | undefined; error: Error | null; isLoading: boolean; refetch: jest.Mock; @@ -24,24 +34,46 @@ const mockedAllRoleAssignments: { data: { results: [ { - isSuperadmin: false, - role: 'course_staff', - org: 'OpenedX', - scope: 'course-v1:OpenedX+DemoX+DemoCourse', - permissionCount: 27, - fullName: 'John Doe', username: 'johndoe', + fullName: 'John Doe', email: 'johndoe@example.com', + // More roles than the three returned, so the row advertises the remainder. + assignmentCount: 10, + assignments: [ + courseAssignment, + { + isSuperadmin: false, + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + scopeName: 'Computer Science Problems', + permissionCount: 11, + }, + { + isSuperadmin: false, + role: 'library_user', + org: 'WGU', + scope: 'lib:WGU:MATH', + scopeName: 'Mathematics Problems', + permissionCount: 4, + }, + ], }, { - isSuperadmin: true, - role: 'super_admin', - org: 'Global', - scope: 'system', - permissionCount: 100, - fullName: 'Jane Admin', username: 'janeadmin', + fullName: 'Jane Admin', email: 'jane@example.com', + // A single role: nothing further to reveal. + assignmentCount: 1, + assignments: [ + { + isSuperadmin: false, + role: 'course_auditor', + org: 'OpenedX', + scope: 'course-v1:OpenedX+Other+Course', + permissionCount: 3, + }, + ], }, ], count: 2, @@ -126,21 +158,25 @@ jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({ const mockUseCourseAuthoringFlag = useCourseAuthoringFlag as jest.Mock; jest.mock('@src/authz-module/data/hooks', () => ({ - useAllRoleAssignments: jest.fn(), + useTeamMembersAssignments: jest.fn(), useOrgs: jest.fn(), useScopes: jest.fn(), })); const mockApiResponses = ( - allAsignmentsResponse = mockedAllRoleAssignments, + teamMembersResponse = mockedTeamMembers, orgResponse = mockedOrgs, scopesResponse = mockedScopes, ) => { - (useAllRoleAssignments as jest.Mock).mockReturnValue(allAsignmentsResponse); + (useTeamMembersAssignments as jest.Mock).mockReturnValue(teamMembersResponse); (useOrgs as jest.Mock).mockReturnValue(orgResponse); (useScopes as jest.Mock).mockReturnValue(scopesResponse); }; +const renderTable = (props = {}) => renderWithAllProviders( + , +); + describe('TeamMembersTable', () => { beforeEach(() => { mockNavigate.mockClear(); @@ -156,10 +192,9 @@ describe('TeamMembersTable', () => { }); }); - it('renders table with role assignments data', async () => { - const presetScope = 'course-v1:OpenedX+DemoX+DemoCourse'; + it('renders one row per user', async () => { mockApiResponses(); - renderWithAllProviders(); + renderTable({ presetScope: 'course-v1:OpenedX+DemoX+DemoCourse' }); await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText('Jane Admin')).toBeInTheDocument(); @@ -168,41 +203,190 @@ describe('TeamMembersTable', () => { }); }); + it('requests the nested assignments capped at MAX_INLINE_ASSIGNMENTS', async () => { + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(useTeamMembersAssignments).toHaveBeenCalledWith( + expect.any(Object), + MAX_INLINE_ASSIGNMENTS, + ); + }); + }); + it('shows loading state initially', () => { - const allAsignmentsResponse = { ...mockedAllRoleAssignments, isLoading: true }; - mockApiResponses(allAsignmentsResponse); - renderWithAllProviders(); + mockApiResponses({ ...mockedTeamMembers, isLoading: true }); + renderTable(); expect(screen.getByRole('status')).toBeInTheDocument(); }); it('shows error toast message', () => { - const allAsignmentsResponse = { - ...mockedAllRoleAssignments, + mockApiResponses({ + ...mockedTeamMembers, isLoading: false, error: new Error('Failed to fetch'), + // @ts-ignore - deliberately partial payload alongside the error data: { results: [] }, - }; - // @ts-expect-error:expected - mockApiResponses(allAsignmentsResponse); - renderWithAllProviders(); + }); + renderTable(); expect(screen.getByText(/Something went wrong on our end./)).toBeInTheDocument(); }); - it('renders table headers correctly', async () => { + + it('renders the user-grouped headers, keeping org, scope and role as filters only', async () => { mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { - 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(); expect(screen.getByText('Actions')).toBeInTheDocument(); }); + // Only the filter buttons remain — these are no longer column headers. + expect(screen.getAllByText('Organization')).toHaveLength(1); + expect(screen.getAllByText('Scope')).toHaveLength(1); + expect(screen.getAllByText('Role')).toHaveLength(1); + }); + + it('renders the first assignment in the collapsed row, showing the scope name not its id', async () => { + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(screen.getByText('Open edX Demo Course')).toBeInTheDocument(); + }); + expect(screen.queryByText('course-v1:OpenedX+DemoX+DemoCourse')).not.toBeInTheDocument(); + }); + + it('falls back to the scope id when the API sends no name', async () => { + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: [{ + ...mockedTeamMembers.data!.results[0], + assignments: [{ ...courseAssignment, scopeName: undefined }], + assignmentCount: 1, + }], + count: 1, + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getByText('course-v1:OpenedX+DemoX+DemoCourse')).toBeInTheDocument(); + }); + }); + + it('counts the roles beyond the one already shown', async () => { + mockApiResponses(); + renderTable(); + // 10 total roles, one of them already on the row. + await waitFor(() => { + expect(screen.getByText('+9 more roles')).toBeInTheDocument(); + }); + }); + + it('omits the toggle for a user with a single role', async () => { + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('Jane Admin')).toBeInTheDocument(); + }); + expect(screen.queryByText('+0 more roles')).not.toBeInTheDocument(); + // Only John Doe's row offers an expansion. + expect(screen.getAllByText(/more roles?$/)).toHaveLength(1); + }); + + it('expands into a breakdown whose first row matches the collapsed badge', async () => { + const user = userEvent.setup(); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('+9 more roles')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('+9 more roles')); + + await waitFor(() => { + expect(screen.getByText('Hide roles')).toBeInTheDocument(); + }); + // The badge stays put, so the role now appears both on the row and in the breakdown. + expect(screen.getAllByText('Course Staff')).toHaveLength(2); + expect(screen.getByText('Library Admin')).toBeInTheDocument(); + expect(screen.getByText('Library User')).toBeInTheDocument(); + }); + + it('reports the absolute role total in the breakdown footer', async () => { + const user = userEvent.setup(); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('+9 more roles')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('+9 more roles')); + + // Three assignments returned out of the user's ten. + await waitFor(() => { + expect(screen.getByText('Showing 03 of 10')).toBeInTheDocument(); + }); + expect(screen.getByRole('link', { name: /View all roles/ })).toHaveAttribute( + 'href', + `${AUTHZ_HOME_PATH}/user/johndoe`, + ); + }); + + it('hides "View all roles" when nothing is truncated', async () => { + const user = userEvent.setup(); + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: [{ + ...mockedTeamMembers.data!.results[0], + assignmentCount: 3, + }], + count: 1, + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getByText('+2 more roles')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('+2 more roles')); + + await waitFor(() => { + expect(screen.getByText('Showing 03 of 03')).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: /View all roles/ })).not.toBeInTheDocument(); + }); + + it('collapses expanded rows when a filter is applied', async () => { + const user = userEvent.setup(); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('+9 more roles')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('+9 more roles')); + await waitFor(() => { + expect(screen.getByText('Hide roles')).toBeInTheDocument(); + }); + + const orgFilter = screen.getByRole('button', { name: /Organization/ }); + await user.click(orgFilter); + const option = await screen.findByText('Organization 1'); + await user.click(option); + + await waitFor(() => { + expect(screen.queryByText('Hide roles')).not.toBeInTheDocument(); + }); }); it('renders view action buttons for each user', async () => { mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { const viewButtons = screen.getAllByRole('button', { name: /view/i }); expect(viewButtons).toHaveLength(2); @@ -212,7 +396,7 @@ describe('TeamMembersTable', () => { it('navigates to user profile when view button is clicked', async () => { const user = userEvent.setup(); mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument(); }); @@ -221,44 +405,82 @@ describe('TeamMembersTable', () => { expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/johndoe'); }); - it('renders safely when role assignments data is undefined', () => { - mockApiResponses({ ...mockedAllRoleAssignments, data: undefined }); - renderWithAllProviders(); - expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); - }); - - it('filters to library roles only when course view is not allowed', async () => { - mockUseViewTeamPermissions.mockReturnValue({ - isCourseViewAllowed: false, - isLibraryViewAllowed: true, + it('keeps the view action enabled while any assignment is viewable', async () => { + // John Doe's course is disabled but his library roles are not; Jane Admin is library-only. + mockUseCourseAuthoringFlag.mockReturnValue({ + isCourseAuthoringEnabled: true, + isCourseEnabled: (scope: string) => scope !== 'course-v1:OpenedX+DemoX+DemoCourse', isLoading: false, }); mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { - expect(useAllRoleAssignments).toHaveBeenCalledWith( - expect.objectContaining({ roles: LIBRARY_ROLE_KEYS }), - ); + const viewButtons = screen.getAllByRole('button', { name: /view/i }); + expect(viewButtons[0]).not.toBeDisabled(); + expect(viewButtons[1]).not.toBeDisabled(); }); }); - it('disables the view action for course assignments in disabled scopes', async () => { + it('disables the view action when every assignment sits in a disabled course', async () => { mockUseCourseAuthoringFlag.mockReturnValue({ isCourseAuthoringEnabled: true, - isCourseEnabled: (scope: string) => scope !== 'course-v1:OpenedX+DemoX+DemoCourse', + isCourseEnabled: () => false, isLoading: false, }); + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: [{ + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: 1, + assignments: [courseAssignment], + }], + count: 1, + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /view/i })).toBeDisabled(); + }); + }); + + it('reports the count in users', async () => { mockApiResponses(); - renderWithAllProviders(); + const { container } = renderTable(); await waitFor(() => { - const viewButtons = screen.getAllByRole('button', { name: /view/i }); - expect(viewButtons[0]).toBeDisabled(); - expect(viewButtons[1]).not.toBeDisabled(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + // Once on the control bar, once in the footer. + expect(within(container).getAllByText('Showing 2 users of 2.')).toHaveLength(2); + }); + + it('renders safely when team members data is undefined', () => { + mockApiResponses({ ...mockedTeamMembers, data: undefined }); + renderTable(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('filters to library roles only when course view is not allowed', async () => { + mockUseViewTeamPermissions.mockReturnValue({ + isCourseViewAllowed: false, + isLibraryViewAllowed: true, + isLoading: false, + }); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(useTeamMembersAssignments).toHaveBeenCalledWith( + expect.objectContaining({ roles: LIBRARY_ROLE_KEYS }), + MAX_INLINE_ASSIGNMENTS, + ); }); }); it('handles empty data gracefully', async () => { - const allAsignmentsResponse = { + mockApiResponses({ data: { results: [], count: 0, @@ -268,9 +490,8 @@ describe('TeamMembersTable', () => { error: null, isLoading: false, refetch: jest.fn(), - }; - mockApiResponses(allAsignmentsResponse); - renderWithAllProviders(); + }); + renderTable(); await waitFor(() => { expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); }); diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index b4b30a36..96c95d42 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -16,18 +16,28 @@ import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilte import ScopesFilter from '@src/authz-module/components/TableControlBar/ScopesFilter'; import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar'; import { getCellHeader } from '@src/authz-module/utils'; -import { - createViewActionCell, NameCell, OrgCell, RoleCell, ScopeCell, -} from '@src/authz-module/components/TableCells'; -import { useAllRoleAssignments } from '@src/authz-module/data/hooks'; -import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; +import { EmailCell, NameCell } from '@src/authz-module/components/TableCells'; +import { useTeamMembersAssignments } from '@src/authz-module/data/hooks'; +import { MAX_INLINE_ASSIGNMENTS, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; import messages from './messages'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; +import AssignedRolesCell from './AssignedRolesCell'; +import CollapseRowsOnChange from './CollapseRowsOnChange'; +import MoreRolesToggle from './MoreRolesToggle'; +import UserAssignmentsSubTable from './UserAssignmentsSubTable'; +import { createTeamMemberViewActionCell } from './TeamMemberViewActionCell'; interface TeamMembersTableProps { presetScope?: string; } +// Org, scope and role stay in the column set — TableControlBar derives its filter +// controls from the columns — but are hidden, since the design surfaces them inside +// each user's role breakdown instead of as top-level columns. They carry no accessor +// (a user row has no single org/scope/role), so each opts into filtering with +// `defaultCanFilter`, which react-table otherwise infers from the accessor. +const HIDDEN_FILTER_COLUMNS = ['org', 'scope', 'role']; + const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const intl = useIntl(); const { showErrorToast } = useToastManager(); @@ -55,13 +65,13 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { }, [isCourseViewAllowed, querySettings]); const { - data: { results: roleAssignments, count } = { results: [], count: 0 }, - isLoading: isLoadingAllRoleAssignments, + data: { results: teamMembers, count } = { results: [], count: 0 }, + isLoading: isLoadingTeamMembers, error, refetch, - } = useAllRoleAssignments(effectiveQuerySettings); + } = useTeamMembersAssignments(effectiveQuerySettings, MAX_INLINE_ASSIGNMENTS); - const viewActionCell = useMemo(() => createViewActionCell({ isCourseEnabled }), [isCourseEnabled]); + const viewActionCell = useMemo(() => createTeamMemberViewActionCell({ isCourseEnabled }), [isCourseEnabled]); const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; @@ -77,80 +87,105 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { useEffect(() => () => fetchData.cancel(), [fetchData]); + const showingUsersLabel = intl.formatMessage( + messages['authz.team.members.table.showing.users.text'], + { pageSize: teamMembers.length, itemCount: count }, + ); + return ( - - - - - + ]} + columns={ + [ + { + id: 'username', + Header: intl.formatMessage(messages['authz.team.members.table.column.name.title']), + accessor: 'username', + Cell: NameCell, + filter: 'text', + Filter: TextFilter, + filterOrder: 1, + }, + { + Header: intl.formatMessage(messages['authz.team.members.table.column.email.title']), + accessor: 'email', + Cell: EmailCell, + disableFilters: true, + filter: 'text', + Filter: TextFilter, + }, + { + id: 'assignedRoles', + Header: intl.formatMessage(messages['authz.team.members.table.column.assigned.roles.title']), + Cell: AssignedRolesCell, + disableFilters: true, + disableSortBy: true, + }, + { + id: 'org', + Header: getCellHeader('org', intl.formatMessage(messages['authz.team.members.table.column.organization.title']), columnsWithFiltersApplied), + filter: 'includesValue', + defaultCanFilter: true, + Filter: OrgFilter, + filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.organization.title']), + filterOrder: 2, + }, + { + id: 'scope', + Header: getCellHeader('scope', intl.formatMessage(messages['authz.team.members.table.column.scope.title']), columnsWithFiltersApplied), + filter: 'includesValue', + defaultCanFilter: true, + Filter: ScopesFilter, + filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.scope.title']), + filterOrder: 4, + }, + { + id: 'role', + Header: getCellHeader('role', intl.formatMessage(messages['authz.team.members.table.column.role.title']), columnsWithFiltersApplied), + filter: 'includesValue', + defaultCanFilter: true, + Filter: RolesFilter, + filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.role.title']), + filterOrder: 3, + }, + ] + } + > + + + + + ); }; diff --git a/src/authz-module/team-members/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/UserAssignmentsSubTable.tsx new file mode 100644 index 00000000..b109f7b9 --- /dev/null +++ b/src/authz-module/team-members/UserAssignmentsSubTable.tsx @@ -0,0 +1,122 @@ +import { useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTable, Icon } from '@openedx/paragon'; +import { ArrowForward, Business } from '@openedx/paragon/icons'; +import { Link } from 'react-router-dom'; +import { buildAuditUserPath, DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; +import { getScopeResourceIcon } from '@src/authz-module/utils'; +import componentMessages from '@src/authz-module/components/messages'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import messages from './messages'; +import { RoleBadge } from './AssignedRolesCell'; + +interface UserAssignmentsSubTableProps { + row: { original: TeamMember }; +} + +type AssignmentCellProps = { row: { original: TeamMemberAssignment } }; + +// Same badge as the collapsed row above, so the breakdown reads consistently. +const RoleBadgeCell = ({ row: assignmentRow }: AssignmentCellProps) => ( + +); + +const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { + const { formatMessage } = useIntl(); + const { role, scope, scopeName } = assignmentRow.original; + const scopeText = DJANGO_MANAGED_ROLES.includes(role) + ? formatMessage(componentMessages['authz.user.table.scope.global.label']) + : scopeName || scope; + + return ( + + + {scopeText} + + ); +}; + +// Mirrors ScopeNameCell's icon treatment so all three columns read alike. Django-managed +// roles span every org, so they show the platform-wide label instead of a single name. +const OrgIconCell = ({ row: assignmentRow }: AssignmentCellProps) => { + const { formatMessage } = useIntl(); + const { role, org } = assignmentRow.original; + const orgText = DJANGO_MANAGED_ROLES.includes(role) + ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) + : org; + + return ( + + + {orgText} + + ); +}; + +/** + * Role breakdown revealed when a team member row is expanded. + * + * Lists the assignments the API returned, in order, so the first entry matches the badge + * still shown in the collapsed row above. The footer total is the user's absolute role + * count, which is unaffected by any active filter and may exceed the rows listed here — + * `View all roles` leads to the audit page when it does. + */ +const UserAssignmentsSubTable = ({ row }: UserAssignmentsSubTableProps) => { + const { formatMessage } = useIntl(); + const { assignments = [], assignmentCount, username } = row.original; + const hasMoreAssignments = assignmentCount > assignments.length; + + const columns = useMemo(() => [ + { + id: 'role', + Header: formatMessage(messages['authz.team.members.subtable.column.role.title']), + accessor: 'role', + Cell: RoleBadgeCell, + }, + { + id: 'scope', + Header: formatMessage(messages['authz.team.members.subtable.column.scope.title']), + accessor: 'scope', + Cell: ScopeNameCell, + }, + { + id: 'org', + Header: formatMessage(messages['authz.team.members.subtable.column.organization.title']), + accessor: 'org', + Cell: OrgIconCell, + }, + ], [formatMessage]); + + return ( +
+ + + + +
+ + {formatMessage(messages['authz.team.members.subtable.showing.text'], { + shown: String(assignments.length).padStart(2, '0'), + total: String(assignmentCount).padStart(2, '0'), + })} + + {hasMoreAssignments && ( + <> + {/* Reuses the module's vertical `hr` divider (see index.scss). */} +
+ + {formatMessage(messages['authz.team.members.subtable.view.all.roles'])} + + + + )} +
+
+ ); +}; + +export default UserAssignmentsSubTable; diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts index 5aa02d6c..a4a7d805 100644 --- a/src/authz-module/team-members/messages.ts +++ b/src/authz-module/team-members/messages.ts @@ -3,8 +3,8 @@ import { defineMessages } from '@openedx/frontend-base'; const messages = defineMessages({ 'authz.team.members.table.column.name.title': { id: 'authz.team.members.table.column.name.title', - defaultMessage: 'Name', - description: 'Team members table name column header', + defaultMessage: 'Username', + description: 'Team members table username column header', }, 'authz.team.members.table.column.email.title': { id: 'authz.team.members.table.column.email.title', @@ -31,7 +31,56 @@ const messages = defineMessages({ defaultMessage: 'Actions', description: 'Team members table actions column header', }, - + 'authz.team.members.table.column.assigned.roles.title': { + id: 'authz.team.members.table.column.assigned.roles.title', + defaultMessage: 'Assigned roles', + description: 'Team members table assigned roles column header', + }, + 'authz.team.members.table.assigned.roles.connector': { + id: 'authz.team.members.table.assigned.roles.connector', + defaultMessage: 'In', + description: 'Connector word placed between a role badge and the scope it applies to, e.g. "Course Admin In Introduction to Data Analysis"', + }, + 'authz.team.members.table.more.roles': { + id: 'authz.team.members.table.more.roles', + defaultMessage: '+{count, plural, one {# more role} other {# more roles}}', + description: 'Link expanding a team member row to reveal their remaining roles. The count excludes the role already shown in the collapsed row.', + }, + 'authz.team.members.table.hide.roles': { + id: 'authz.team.members.table.hide.roles', + defaultMessage: 'Hide roles', + description: 'Link collapsing an expanded team member row', + }, + 'authz.team.members.subtable.column.role.title': { + id: 'authz.team.members.subtable.column.role.title', + defaultMessage: 'Role', + description: 'Role column header of the nested assignments table inside an expanded team member row', + }, + 'authz.team.members.subtable.column.scope.title': { + id: 'authz.team.members.subtable.column.scope.title', + defaultMessage: 'Scope', + description: 'Scope column header of the nested assignments table inside an expanded team member row', + }, + 'authz.team.members.subtable.column.organization.title': { + id: 'authz.team.members.subtable.column.organization.title', + defaultMessage: 'Organization', + description: 'Organization column header of the nested assignments table inside an expanded team member row', + }, + 'authz.team.members.subtable.showing.text': { + id: 'authz.team.members.subtable.showing.text', + defaultMessage: 'Showing {shown} of {total}', + description: 'Footer of the nested assignments table stating how many of the user total number of roles are listed', + }, + 'authz.team.members.subtable.view.all.roles': { + id: 'authz.team.members.subtable.view.all.roles', + defaultMessage: 'View all roles', + description: 'Link taking the user to the full profile listing every role assigned to a team member', + }, + 'authz.team.members.table.showing.users.text': { + id: 'authz.team.members.table.showing.users.text', + defaultMessage: 'Showing {pageSize} users of {itemCount}.', + description: 'Text stating how many team members are listed on the current page out of the total count', + }, }); export default messages; diff --git a/src/authz-module/utils.tsx b/src/authz-module/utils.tsx index fd2a422e..eb2cf35d 100644 --- a/src/authz-module/utils.tsx +++ b/src/authz-module/utils.tsx @@ -1,5 +1,7 @@ import { Icon } from '@openedx/paragon'; import { FilterList } from '@openedx/paragon/icons'; +import { RESOURCE_ICONS } from './components/constants'; +import { DJANGO_MANAGED_ROLES } from './constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; /** @@ -31,6 +33,17 @@ export const getCellHeader = (columnId: string, columnTitle: string, filtersAppl return columnTitle; }; +/** + * Picks the resource icon for a scope from the role that grants it: Django-managed roles + * are platform-wide, `lib*` roles point at libraries, and everything else at courses. + */ +export const getScopeResourceIcon = (role: string) => { + if (DJANGO_MANAGED_ROLES.includes(role)) { + return RESOURCE_ICONS.GLOBAL; + } + return role?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; +}; + export const getScopeManageAction = (scope: string) => { if (scope.startsWith('lib')) { return CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; diff --git a/src/types.ts b/src/types.ts index d605c420..58273cea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -107,3 +107,29 @@ export interface RoleToDelete { export type UserRoleWithPermissions = UserRole & { canManageScope?: boolean; }; + +/** A single role assignment nested under a team member in the user-grouped listing. */ +export interface TeamMemberAssignment { + isSuperadmin: boolean; + role: string; + org: string; + /** Scope external key (e.g. `course-v1:Org+Course+Run`). Identifies the scope and is + * what the scope filter sends back to the API — not shown to the user. */ + scope: string; + /** Human-readable scope name shown in the table; falls back to `scope` when absent. */ + scopeName?: string; + permissionCount: number; +} + +/** + * A team member as returned by the user-grouped assignments endpoint: one entry per + * user, carrying up to `assignments_limit` of their assignments. `assignmentCount` is + * the user's absolute total, so it can exceed `assignments.length`. + */ +export interface TeamMember { + username: string; + fullName: string; + email: string; + assignmentCount: number; + assignments: TeamMemberAssignment[]; +} From 7196018efb31fb4610b8a5476c43e11b82da3a53 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 9 Sep 2026 17:12:00 +1000 Subject: [PATCH 03/24] fix: display agregate message and fic styles for subtable --- .../components/TableCells.test.tsx | 111 ------------------ src/authz-module/components/TableCells.tsx | 25 +--- src/authz-module/components/messages.ts | 5 - src/authz-module/constants.ts | 3 + src/authz-module/data/hooks.test.tsx | 1 - src/authz-module/index.scss | 13 +- src/authz-module/messages.ts | 22 ++++ .../hooks/useScopeListData.ts | 11 +- .../role-assignation-wizard/messages.ts | 20 ---- .../team-members/AssignedRolesCell.tsx | 29 +++-- .../team-members/TeamMemberCells.test.tsx | 93 +++++++++++++++ .../team-members/TeamMemberCells.tsx | 38 ++++++ .../TeamMemberViewActionCell.test.tsx | 15 ++- .../team-members/TeamMemberViewActionCell.tsx | 13 +- .../team-members/TeamMembersTable.test.tsx | 31 +++-- .../team-members/TeamMembersTable.tsx | 9 +- .../team-members/UserAssignmentsSubTable.tsx | 42 +++++-- src/authz-module/team-members/messages.ts | 11 +- src/types.ts | 12 +- 19 files changed, 283 insertions(+), 221 deletions(-) create mode 100644 src/authz-module/team-members/TeamMemberCells.test.tsx create mode 100644 src/authz-module/team-members/TeamMemberCells.tsx diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index 12ef9c13..db50145a 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -3,7 +3,6 @@ import { initializeMocks, renderWrapper } from '@src/testUtils'; import userEvent from '@testing-library/user-event'; import { DataTableContext } from '@openedx/paragon'; import { - NameCell, RoleCell, OrgCell, ScopeCell, @@ -28,116 +27,6 @@ describe('TableCells Components', () => { 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: { - id: '0', - original: mockUserRole, - }, - }; - beforeEach(() => { - initializeMocks(); - }); - - 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: { - id: '0', - original: { - ...mockUserRole, - fullName: undefined, - }, - }, - }; - - renderWrapper(); - expect(screen.getByText('johndoe')).toBeInTheDocument(); - }); - - it('displays username when full name is empty string', () => { - const propsWithEmptyFullName = { - row: { - id: '0', - original: { - ...mockUserRole, - fullName: '', - }, - }, - }; - - renderWrapper(); - expect(screen.getByText('johndoe')).toBeInTheDocument(); - }); - - it('shows current user indicator when username matches authenticated user', () => { - const currentUserProps = { - row: { - id: '0', - 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: { - id: '0', - 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('RoleCell', () => { const mockCell = { getCellProps: jest.fn(() => ({ 'data-testid': 'role-cell' })), diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 1eb79e30..4b585999 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -1,4 +1,4 @@ -import { SiteContext, useIntl } from '@openedx/frontend-base'; +import { useIntl } from '@openedx/frontend-base'; import { Delete, ExpandMore, Info, @@ -71,27 +71,6 @@ export const DisabledCourseActionButton = ({ ); }; -const NameCell = ({ row }: CellProps) => { - const intl = useIntl(); - const { authenticatedUser } = useContext(SiteContext); - const username = authenticatedUser?.username; - const displayName = row.original.fullName || row.original.username || ''; - - if (row.original.username === username) { - return ( - - {displayName} - {intl.formatMessage(messages['authz.table.username.current'])} - - ); - } - return {displayName}; -}; - -const EmailCell = ({ value }: CellPropsWithValue) => ( - {value} -); - const OrgCell = ({ value, row }: CellPropsWithValue) => { const { formatMessage } = useIntl(); return ( @@ -257,8 +236,6 @@ const createActionsCell = (extraProps: ActionsCellExtraProps) => function custom }; export { - NameCell, - EmailCell, RoleCell, OrgCell, ScopeCell, diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index d4b35f57..2db86fd0 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -86,11 +86,6 @@ const messages = defineMessages({ defaultMessage: 'View all permissions', description: 'Text for the link to view all permissions in the user table', }, - 'authz.table.username.current': { - id: 'authz.table.username.current', - defaultMessage: '(Me)', - description: 'Indicates the current user in the team members table', - }, 'authz.table.column.actions.view.title': { id: 'authz.table.column.actions.view.title', diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index e62f49e3..0d97aae6 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -28,6 +28,9 @@ export const getPlatformAggregateScopeKey = (contextType: ContextType): string = return scope; }; +/** The `org` an assignment carries when it spans every organization. */ +export const ALL_ORGS_KEY = '*'; + export const DEFAULT_TOAST_DELAY = 5000; export const RETRY_TOAST_DELAY = 120_000; // 2 minutes diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index c9d14062..2013c773 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -37,7 +37,6 @@ const mockAssignments = { assignmentCount: 5, assignments: [ { - isSuperadmin: false, role: 'course_staff', org: 'OpenedX', scope: 'course-v1:OpenedX+DemoX+DemoCourse', diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 344dd78c..b2fbd32c 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -89,7 +89,7 @@ the design's character budget: ~24 for username, ~28 for email, the bulk to the role summary, and just enough for the toggle and the action icon. */ - .team-members-table { + &.team-members-table { .pgn__data-table { table-layout: fixed; } @@ -130,6 +130,17 @@ padding: 0; } + /* + Header band matching the resource-category rows of the roles & permissions matrix + (`bg-info-100 text-primary` in PermissionTable), so the two nested tables in the + module read as the same component. Paragon's DataTable renders its own `thead`, so + the utilities cannot be passed as a className and the tokens behind them are used. + */ + thead th { + background-color: var(--pgn-color-info-100); + color: var(--pgn-color-primary-base); + } + /* Paragon stripes rows with `.pgn__data-table.is-striped tr:nth-child(2n)` — a descendant selector, so the striped outer table reaches into this nested one and diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts index 70d74cba..be2affd1 100644 --- a/src/authz-module/messages.ts +++ b/src/authz-module/messages.ts @@ -42,6 +42,28 @@ const messages = defineMessages( defaultMessage: 'The request took too long.

Please check your connection and try again.', description: 'Request timeout message', }, + // Aggregate scope labels. Shared: the wizard offers these scopes when assigning a + // role, and the team members table names them when displaying one already assigned. + 'authz.scope.aggregate.platform.course': { + id: 'authz.scope.aggregate.platform.course', + defaultMessage: 'All courses on the platform', + description: 'Label for the scope covering every course on the platform', + }, + 'authz.scope.aggregate.platform.library': { + id: 'authz.scope.aggregate.platform.library', + defaultMessage: 'All libraries on the platform', + description: 'Label for the scope covering every library on the platform', + }, + 'authz.scope.aggregate.org.course': { + id: 'authz.scope.aggregate.org.course', + defaultMessage: 'All courses in this organization', + description: 'Label for the scope covering every course within one organization', + }, + 'authz.scope.aggregate.org.library': { + id: 'authz.scope.aggregate.org.library', + defaultMessage: 'All libraries in this organization', + description: 'Label for the scope covering every library within one organization', + }, 'authz.team.toast.retry.label': { id: 'authz.team.toast.retry.label', defaultMessage: 'Retry', diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index 3c5f2737..f1756268 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -5,7 +5,8 @@ import { useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; import type { ContextType } from '@src/authz-module/constants'; -import messages from '@src/authz-module/role-assignation-wizard/messages'; +import moduleMessages from '@src/authz-module/messages'; +import messages from '../messages'; import useScopePermissions from './useScopePermissions'; interface UseScopeListDataParams { @@ -84,12 +85,12 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) : intl.formatMessage(messages['wizard.step2.scope.aggregate.description.library']); const platformAggregateLabel = contextType === 'course' - ? intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.course']) - : intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.library']); + ? intl.formatMessage(moduleMessages['authz.scope.aggregate.platform.course']) + : intl.formatMessage(moduleMessages['authz.scope.aggregate.platform.library']); const orgAggregateLabel = contextType === 'course' - ? intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.course']) - : intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.library']); + ? intl.formatMessage(moduleMessages['authz.scope.aggregate.org.course']) + : intl.formatMessage(moduleMessages['authz.scope.aggregate.org.library']); const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission) ? { diff --git a/src/authz-module/role-assignation-wizard/messages.ts b/src/authz-module/role-assignation-wizard/messages.ts index 14318297..76aa334c 100644 --- a/src/authz-module/role-assignation-wizard/messages.ts +++ b/src/authz-module/role-assignation-wizard/messages.ts @@ -203,16 +203,6 @@ const messages = defineMessages({ defaultMessage: 'Includes current and future libraries', description: 'Description for the platform-wide aggregate scope item when context type is library', }, - 'wizard.step2.scope.aggregate.platform.label.course': { - id: 'wizard.step2.scope.aggregate.platform.label.course', - defaultMessage: 'All courses on the platform', - description: 'Display name for the platform-wide aggregate scope item when context type is course', - }, - 'wizard.step2.scope.aggregate.platform.label.library': { - id: 'wizard.step2.scope.aggregate.platform.label.library', - defaultMessage: 'All libraries on the platform', - description: 'Display name for the platform-wide aggregate scope item when context type is library', - }, // ScopeList — org section header 'wizard.step2.scopeList.orgLabel': { @@ -222,16 +212,6 @@ const messages = defineMessages({ }, // ScopeList — org-level aggregate scope items - 'wizard.step2.scopeList.aggregate.label.course': { - id: 'wizard.step2.scopeList.aggregate.label.course', - defaultMessage: 'All courses in this organization', - description: 'Display name for the org-wide aggregate scope item when context type is course', - }, - 'wizard.step2.scopeList.aggregate.label.library': { - id: 'wizard.step2.scopeList.aggregate.label.library', - defaultMessage: 'All libraries in this organization', - description: 'Display name for the org-wide aggregate scope item when context type is library', - }, // ScopeList — loading / empty states 'wizard.step2.scopeList.loading': { diff --git a/src/authz-module/team-members/AssignedRolesCell.tsx b/src/authz-module/team-members/AssignedRolesCell.tsx index a856fb0f..fe574018 100644 --- a/src/authz-module/team-members/AssignedRolesCell.tsx +++ b/src/authz-module/team-members/AssignedRolesCell.tsx @@ -1,10 +1,14 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { Business, Person } from '@openedx/paragon/icons'; -import { DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants'; +import { + ALL_ORGS_KEY, CONTEXT_TYPES, getOrgAggregateScopeKey, + getPlatformAggregateScopeKey, MAP_ROLE_KEY_TO_LABEL, +} from '@src/authz-module/constants'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import moduleMessages from '@src/authz-module/messages'; import messages from './messages'; interface AssignedRolesCellProps { @@ -30,14 +34,25 @@ export const RoleBadge = ({ role }: { role: string }) => ( export const AssignmentSummary = ({ assignment }: AssignmentSummaryProps) => { const { formatMessage } = useIntl(); const { - role, scope, scopeName, org, + role, scope, scopeDisplayName, org, } = assignment; - const isDjangoRole = DJANGO_MANAGED_ROLES.includes(role); + const contextType = role?.startsWith('lib') ? CONTEXT_TYPES.LIBRARY : CONTEXT_TYPES.COURSE; + const isLibrary = contextType === CONTEXT_TYPES.LIBRARY; - const scopeText = isDjangoRole - ? formatMessage(componentMessages['authz.user.table.scope.global.label']) - : scopeName || scope; - const orgText = isDjangoRole + // An aggregate scope covers every course/library across the platform or within one org, + // so it names no single resource and the API sends an empty display name for it. + let scopeText = scopeDisplayName || scope; + if (scope === getPlatformAggregateScopeKey(contextType)) { + scopeText = formatMessage(isLibrary + ? moduleMessages['authz.scope.aggregate.platform.library'] + : moduleMessages['authz.scope.aggregate.platform.course']); + } else if (scope === getOrgAggregateScopeKey(contextType, org)) { + scopeText = formatMessage(isLibrary + ? moduleMessages['authz.scope.aggregate.org.library'] + : moduleMessages['authz.scope.aggregate.org.course']); + } + + const orgText = org === ALL_ORGS_KEY ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) : org; diff --git a/src/authz-module/team-members/TeamMemberCells.test.tsx b/src/authz-module/team-members/TeamMemberCells.test.tsx new file mode 100644 index 00000000..470238b3 --- /dev/null +++ b/src/authz-module/team-members/TeamMemberCells.test.tsx @@ -0,0 +1,93 @@ +import { screen } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import { EmailCell, NameCell } from './TeamMemberCells'; + +describe('NameCell', () => { + const teamMember = { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: 1, + assignments: [], + }; + const mockCellProps = { + row: { + id: '0', + original: teamMember, + }, + }; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'testuser@example.com', + }, + }); + }); + + it('displays the username, never the full name', () => { + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('renders nothing when the user has no username', () => { + const propsWithBlankUsername = { + row: { + id: '0', + original: { + ...teamMember, + username: '', + }, + }, + }; + + renderWrapper(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('shows current user indicator when username matches authenticated user', () => { + const currentUserProps = { + row: { + id: '0', + original: { + ...teamMember, + username: 'testuser', + fullName: 'Test User', + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); + }); + + it('does not show current user indicator when username does not match authenticated user', () => { + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); + + it('handles missing username in authenticated user gracefully', () => { + const contextWithoutUsername = { + authenticatedUser: { + username: undefined, + email: 'testuser@example.com', + }, + }; + + renderWrapper(, contextWithoutUsername); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); +}); + +describe('EmailCell', () => { + it('displays the email address', () => { + renderWrapper(); + expect(screen.getByText('johndoe@example.com')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/team-members/TeamMemberCells.tsx b/src/authz-module/team-members/TeamMemberCells.tsx new file mode 100644 index 00000000..1180119b --- /dev/null +++ b/src/authz-module/team-members/TeamMemberCells.tsx @@ -0,0 +1,38 @@ +import { useContext } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { AppContext } from '@edx/frontend-platform/react'; +import type { TeamMember } from '@src/types'; +import messages from './messages'; + +interface NameCellProps { + row: { original: TeamMember }; +} + +interface EmailCellProps { + value: string; +} + +/** + * Username column. Shows the username verbatim — the API also sends `fullName`, but this + * column is headed "Username" and is what the table sorts and searches on. The row for the + * signed-in user is marked so they can find themselves in the list. + */ +export const NameCell = ({ row }: NameCellProps) => { + const intl = useIntl(); + const { authenticatedUser } = useContext(AppContext); + const { username = '' } = row.original; + const isCurrentUser = username === authenticatedUser?.username; + + return ( + + {username} + {isCurrentUser && ( + {intl.formatMessage(messages['authz.table.username.current'])} + )} + + ); +}; + +export const EmailCell = ({ value }: EmailCellProps) => ( + {value} +); diff --git a/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx b/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx index 2b741acd..9c3ad1a9 100644 --- a/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx +++ b/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx @@ -14,18 +14,18 @@ jest.mock('react-router-dom', () => ({ })); const courseAssignment = { - isSuperadmin: false, role: 'course_staff', org: 'OpenedX', scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeDisplayName: 'Open edX Demo Course', permissionCount: 27, }; const libraryAssignment = { - isSuperadmin: false, role: 'library_admin', org: 'WGU', scope: 'lib:WGU:CSPROB', + scopeDisplayName: 'Computer Science Problems', permissionCount: 11, }; @@ -99,6 +99,17 @@ describe('TeamMemberViewActionCell', () => { expect(screen.getByText(/manage its team in Studio instead/i)).toBeInTheDocument(); }); + it('stays enabled when the returned assignments are only part of the user total', () => { + // Every returned assignment sits in a disabled course, but seven more were not returned. + renderWrapper( + false} + />, + ); + expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); + }); + it('never calls the flag check with a missing scope', () => { const isCourseEnabled = jest.fn(() => true); renderWrapper( diff --git a/src/authz-module/team-members/TeamMemberViewActionCell.tsx b/src/authz-module/team-members/TeamMemberViewActionCell.tsx index 9a34d190..d9a80b69 100644 --- a/src/authz-module/team-members/TeamMemberViewActionCell.tsx +++ b/src/authz-module/team-members/TeamMemberViewActionCell.tsx @@ -2,7 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { IconButton } from '@openedx/paragon'; import { Visibility } from '@openedx/paragon/icons'; import { useNavigate } from 'react-router-dom'; -import { buildAuditUserPath, DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; +import { buildAuditUserPath } from '@src/authz-module/constants'; import { DisabledCourseActionButton } from '@src/authz-module/components/TableCells'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; @@ -13,8 +13,7 @@ interface TeamMemberViewActionCellProps { } const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled?: (scope: string) => boolean) => { - const isCourseScope = !assignment.role?.startsWith('lib') - && !DJANGO_MANAGED_ROLES.includes(assignment.role); + const isCourseScope = !assignment.role?.startsWith('lib'); if (!isCourseScope || isCourseEnabled === undefined) { return true; } @@ -27,15 +26,17 @@ const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled?: (scope: * Rows are users rather than single assignments, so the course-authoring flag is evaluated * across the user's assignments: the action stays enabled while at least one of them is * viewable, and is only disabled when every one sits in a course that hasn't moved to the - * new roles experience. Note `assignments` is capped by `assignments_limit`, so this reads - * the assignments actually returned, not necessarily the user's full set. + * new roles experience. `assignments` is capped by `assignments_limit`, so a user whose + * total exceeds the returned slice may hold viewable roles it does not contain; those rows + * stay enabled rather than being blocked on incomplete evidence. */ const TeamMemberViewActionCell = ({ row, isCourseEnabled }: TeamMemberViewActionCellProps) => { const { formatMessage } = useIntl(); const navigate = useNavigate(); - const { assignments = [], username } = row.original; + const { assignments = [], assignmentCount, username } = row.original; const hasViewableAssignment = assignments.length === 0 + || assignmentCount > assignments.length || assignments.some((assignment) => isViewable(assignment, isCourseEnabled)); if (!hasViewableAssignment) { diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 3a376f89..72540ad1 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -17,11 +17,10 @@ jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ const mockUseViewTeamPermissions = useViewTeamPermissions as jest.Mock; const courseAssignment = { - isSuperadmin: false, role: 'course_staff', org: 'OpenedX', scope: 'course-v1:OpenedX+DemoX+DemoCourse', - scopeName: 'Open edX Demo Course', + scopeDisplayName: 'Open edX Demo Course', permissionCount: 27, }; @@ -42,19 +41,17 @@ const mockedTeamMembers: { assignments: [ courseAssignment, { - isSuperadmin: false, role: 'library_admin', org: 'WGU', scope: 'lib:WGU:CSPROB', - scopeName: 'Computer Science Problems', + scopeDisplayName: 'Computer Science Problems', permissionCount: 11, }, { - isSuperadmin: false, role: 'library_user', org: 'WGU', scope: 'lib:WGU:MATH', - scopeName: 'Mathematics Problems', + scopeDisplayName: 'Mathematics Problems', permissionCount: 4, }, ], @@ -67,10 +64,10 @@ const mockedTeamMembers: { assignmentCount: 1, assignments: [ { - isSuperadmin: false, role: 'course_auditor', org: 'OpenedX', scope: 'course-v1:OpenedX+Other+Course', + scopeDisplayName: 'Another Course', permissionCount: 3, }, ], @@ -196,8 +193,10 @@ describe('TeamMembersTable', () => { mockApiResponses(); renderTable({ presetScope: 'course-v1:OpenedX+DemoX+DemoCourse' }); await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); - expect(screen.getByText('Jane Admin')).toBeInTheDocument(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.getByText('janeadmin')).toBeInTheDocument(); + // The column is Username: full names are carried by the API but not displayed. + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); expect(screen.getByText('johndoe@example.com')).toBeInTheDocument(); expect(screen.getByText('jane@example.com')).toBeInTheDocument(); }); @@ -257,14 +256,14 @@ describe('TeamMembersTable', () => { expect(screen.queryByText('course-v1:OpenedX+DemoX+DemoCourse')).not.toBeInTheDocument(); }); - it('falls back to the scope id when the API sends no name', async () => { + it('falls back to the scope id when the API sends no display name', async () => { mockApiResponses({ ...mockedTeamMembers, data: { ...mockedTeamMembers.data!, results: [{ ...mockedTeamMembers.data!.results[0], - assignments: [{ ...courseAssignment, scopeName: undefined }], + assignments: [{ ...courseAssignment, scopeDisplayName: '' }], assignmentCount: 1, }], count: 1, @@ -289,7 +288,7 @@ describe('TeamMembersTable', () => { mockApiResponses(); renderTable(); await waitFor(() => { - expect(screen.getByText('Jane Admin')).toBeInTheDocument(); + expect(screen.getByText('janeadmin')).toBeInTheDocument(); }); expect(screen.queryByText('+0 more roles')).not.toBeInTheDocument(); // Only John Doe's row offers an expansion. @@ -398,7 +397,7 @@ describe('TeamMembersTable', () => { mockApiResponses(); renderTable(); await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); }); const viewButtons = screen.getAllByRole('button', { name: /view/i }); await user.click(viewButtons[0]); @@ -451,7 +450,7 @@ describe('TeamMembersTable', () => { mockApiResponses(); const { container } = renderTable(); await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); }); // Once on the control bar, once in the footer. expect(within(container).getAllByText('Showing 2 users of 2.')).toHaveLength(2); @@ -460,7 +459,7 @@ describe('TeamMembersTable', () => { it('renders safely when team members data is undefined', () => { mockApiResponses({ ...mockedTeamMembers, data: undefined }); renderTable(); - expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + expect(screen.queryByText('johndoe')).not.toBeInTheDocument(); }); it('filters to library roles only when course view is not allowed', async () => { @@ -493,7 +492,7 @@ describe('TeamMembersTable', () => { }); renderTable(); await waitFor(() => { - expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + expect(screen.queryByText('johndoe')).not.toBeInTheDocument(); }); }); }); diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index 96c95d42..fa5dd198 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -16,12 +16,13 @@ import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilte import ScopesFilter from '@src/authz-module/components/TableControlBar/ScopesFilter'; import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar'; import { getCellHeader } from '@src/authz-module/utils'; -import { EmailCell, NameCell } from '@src/authz-module/components/TableCells'; + import { useTeamMembersAssignments } from '@src/authz-module/data/hooks'; import { MAX_INLINE_ASSIGNMENTS, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; import messages from './messages'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; import AssignedRolesCell from './AssignedRolesCell'; +import { EmailCell, NameCell } from './TeamMemberCells'; import CollapseRowsOnChange from './CollapseRowsOnChange'; import MoreRolesToggle from './MoreRolesToggle'; import UserAssignmentsSubTable from './UserAssignmentsSubTable'; @@ -112,7 +113,9 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { hiddenColumns: HIDDEN_FILTER_COLUMNS, }} isLoading={isLoadingTeamMembers} - renderRowSubComponent={UserAssignmentsSubTable} + renderRowSubComponent={({ row }) => ( + + )} additionalColumns={[ { id: 'moreRoles', @@ -129,7 +132,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { [ { id: 'username', - Header: intl.formatMessage(messages['authz.team.members.table.column.name.title']), + Header: intl.formatMessage(messages['authz.team.members.table.column.username.title']), accessor: 'username', Cell: NameCell, filter: 'text', diff --git a/src/authz-module/team-members/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/UserAssignmentsSubTable.tsx index b109f7b9..3e6cac9b 100644 --- a/src/authz-module/team-members/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/UserAssignmentsSubTable.tsx @@ -1,12 +1,16 @@ import { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { DataTable, Icon } from '@openedx/paragon'; +import { Card, DataTable, Icon } from '@openedx/paragon'; import { ArrowForward, Business } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; -import { buildAuditUserPath, DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; +import { + ALL_ORGS_KEY, buildAuditUserPath, CONTEXT_TYPES, getOrgAggregateScopeKey, + getPlatformAggregateScopeKey, +} from '@src/authz-module/constants'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import moduleMessages from '@src/authz-module/messages'; import messages from './messages'; import { RoleBadge } from './AssignedRolesCell'; @@ -23,10 +27,22 @@ const RoleBadgeCell = ({ row: assignmentRow }: AssignmentCellProps) => ( const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { const { formatMessage } = useIntl(); - const { role, scope, scopeName } = assignmentRow.original; - const scopeText = DJANGO_MANAGED_ROLES.includes(role) - ? formatMessage(componentMessages['authz.user.table.scope.global.label']) - : scopeName || scope; + const { + role, scope, scopeDisplayName, org, + } = assignmentRow.original; + const contextType = role?.startsWith('lib') ? CONTEXT_TYPES.LIBRARY : CONTEXT_TYPES.COURSE; + const isLibrary = contextType === CONTEXT_TYPES.LIBRARY; + + let scopeText = scopeDisplayName || scope; + if (scope === getPlatformAggregateScopeKey(contextType)) { + scopeText = formatMessage(isLibrary + ? moduleMessages['authz.scope.aggregate.platform.library'] + : moduleMessages['authz.scope.aggregate.platform.course']); + } else if (scope === getOrgAggregateScopeKey(contextType, org)) { + scopeText = formatMessage(isLibrary + ? moduleMessages['authz.scope.aggregate.org.library'] + : moduleMessages['authz.scope.aggregate.org.course']); + } return ( @@ -36,12 +52,12 @@ const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { ); }; -// Mirrors ScopeNameCell's icon treatment so all three columns read alike. Django-managed -// roles span every org, so they show the platform-wide label instead of a single name. +// Mirrors ScopeNameCell's icon treatment so all three columns read alike. A platform-wide +// aggregate carries no single org, so it shows the all-organizations label instead. const OrgIconCell = ({ row: assignmentRow }: AssignmentCellProps) => { const { formatMessage } = useIntl(); - const { role, org } = assignmentRow.original; - const orgText = DJANGO_MANAGED_ROLES.includes(role) + const { org } = assignmentRow.original; + const orgText = org === ALL_ORGS_KEY ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) : org; @@ -88,7 +104,7 @@ const UserAssignmentsSubTable = ({ row }: UserAssignmentsSubTableProps) => { ], [formatMessage]); return ( -
+ { -
+
{formatMessage(messages['authz.team.members.subtable.showing.text'], { shown: String(assignments.length).padStart(2, '0'), @@ -115,7 +131,7 @@ const UserAssignmentsSubTable = ({ row }: UserAssignmentsSubTableProps) => { )}
-
+
); }; diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts index a4a7d805..ba7da908 100644 --- a/src/authz-module/team-members/messages.ts +++ b/src/authz-module/team-members/messages.ts @@ -1,11 +1,16 @@ import { defineMessages } from '@openedx/frontend-base'; const messages = defineMessages({ - 'authz.team.members.table.column.name.title': { - id: 'authz.team.members.table.column.name.title', + 'authz.team.members.table.column.username.title': { + id: 'authz.team.members.table.column.username.title', defaultMessage: 'Username', description: 'Team members table username column header', }, + 'authz.table.username.current': { + id: 'authz.table.username.current', + defaultMessage: '(Me)', + description: 'Indicates the current user in the team members table', + }, 'authz.team.members.table.column.email.title': { id: 'authz.team.members.table.column.email.title', defaultMessage: 'Email', @@ -78,7 +83,7 @@ const messages = defineMessages({ }, 'authz.team.members.table.showing.users.text': { id: 'authz.team.members.table.showing.users.text', - defaultMessage: 'Showing {pageSize} users of {itemCount}.', + defaultMessage: 'Showing {pageSize, plural, one {# user} other {# users}} of {itemCount}.', description: 'Text stating how many team members are listed on the current page out of the total count', }, }); diff --git a/src/types.ts b/src/types.ts index 58273cea..d44b9d22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,16 +108,20 @@ export type UserRoleWithPermissions = UserRole & { canManageScope?: boolean; }; -/** A single role assignment nested under a team member in the user-grouped listing. */ +/** + * A single role assignment nested under a team member in the user-grouped listing. + * Mirrors `results[].assignments[]` of `GET /api/authz/v1/users/`. + */ export interface TeamMemberAssignment { - isSuperadmin: boolean; role: string; org: string; /** Scope external key (e.g. `course-v1:Org+Course+Run`). Identifies the scope and is * what the scope filter sends back to the API — not shown to the user. */ scope: string; - /** Human-readable scope name shown in the table; falls back to `scope` when absent. */ - scopeName?: string; + /** Human-readable scope name shown in the table. The API returns an empty string for + * glob scopes and for scopes whose backing course or library no longer exists, so + * callers fall back to `scope`. */ + scopeDisplayName: string; permissionCount: number; } From f2af7aff83f6476f99639ee07428b4524763ab84 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 9 Sep 2026 17:25:01 +1000 Subject: [PATCH 04/24] refactor: reorganize and extract scope aggregate info in a get function --- src/authz-module/components/TableCells.tsx | 31 +++++++++++----- src/authz-module/constants.test.ts | 36 ++++++++++++++++++- src/authz-module/constants.ts | 23 ++++++++++++ src/authz-module/messages.ts | 25 ++++++++++--- .../hooks/useScopeListData.ts | 14 ++++---- .../team-members/AssignedRolesCell.tsx | 24 ++++--------- .../team-members/UserAssignmentsSubTable.tsx | 28 +++++---------- src/authz-module/utils.tsx | 15 ++++---- 8 files changed, 129 insertions(+), 67 deletions(-) diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 4b585999..2d177dd6 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -6,13 +6,16 @@ import { import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; import { useContext, useMemo, type ComponentProps } from 'react'; import { - ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL, + ADMIN_ROLES, ALL_ORGS_KEY, CONTEXT_TYPES, DJANGO_MANAGED_ROLES, getAggregateScopeType, + getPlatformAggregateScopeKey, getScopeContextType, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, type DataTableCellProps, } from '@openedx/paragon'; import { getScopeResourceIcon } from '@src/authz-module/utils'; +import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; +import { RESOURCE_ICONS } from './constants'; import messages from './messages'; import ViewMoreLink from './ViewMoreLink'; @@ -73,9 +76,11 @@ export const DisabledCourseActionButton = ({ const OrgCell = ({ value, row }: CellPropsWithValue) => { const { formatMessage } = useIntl(); + // The backend returns '*' as the org wildcard, meaning the role spans every organization. + const isAllOrgs = DJANGO_MANAGED_ROLES.includes(row.original.role) || value === ALL_ORGS_KEY; return ( - {DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value} + {isAllOrgs ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value} ); }; @@ -83,12 +88,22 @@ const OrgCell = ({ value, row }: CellPropsWithValue) => { const ScopeCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); - const { scopeText, iconSrc } = useMemo(() => ({ - scopeText: DJANGO_MANAGED_ROLES.includes(row.original.role) - ? formatMessage(messages['authz.user.table.scope.global.label']) - : row.original.scope, - iconSrc: getScopeResourceIcon(row.original.role), - }), [row.original.role, row.original.scope, formatMessage]); + const { scopeText, iconSrc } = useMemo(() => { + const { role, scope, org } = row.original; + if (DJANGO_MANAGED_ROLES.includes(role) || scope === getPlatformAggregateScopeKey(CONTEXT_TYPES.GLOBAL)) { + return { + scopeText: formatMessage(messages['authz.user.table.scope.global.label']), + iconSrc: RESOURCE_ICONS.GLOBAL, + }; + } + const aggregateType = getAggregateScopeType(scope, org); + return { + scopeText: aggregateType + ? formatMessage(AGGREGATE_SCOPE_LABELS[aggregateType][getScopeContextType(scope)]) + : scope, + iconSrc: getScopeResourceIcon(scope), + }; + }, [row.original, formatMessage]); return ( diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts index 6423e965..0a00acb7 100644 --- a/src/authz-module/constants.test.ts +++ b/src/authz-module/constants.test.ts @@ -1,5 +1,6 @@ import { - AUTHZ_HOME_PATH, buildAuditUserPath, buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, + AUTHZ_HOME_PATH, buildAuditUserPath, buildWizardPath, getAggregateScopeType, + getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, } from './constants'; import type { ContextType } from './constants'; @@ -74,3 +75,36 @@ describe('getPlatformAggregateScopeKey', () => { expect(() => getPlatformAggregateScopeKey('unknown' as ContextType)).toThrow('Unknown contextType: "unknown"'); }); }); +describe('getAggregateScopeType', () => { + it('recognizes the platform-wide course scope', () => { + expect(getAggregateScopeType('course-v1:*', '*')).toBe('platform'); + }); + + it('recognizes the platform-wide library scope', () => { + expect(getAggregateScopeType('lib:*', '*')).toBe('platform'); + }); + + it('recognizes an org-wide course scope', () => { + expect(getAggregateScopeType('course-v1:MIT+*', 'MIT')).toBe('org'); + }); + + it('recognizes an org-wide library scope', () => { + expect(getAggregateScopeType('lib:MIT:*', 'MIT')).toBe('org'); + }); + + it('returns null for a single course scope', () => { + expect(getAggregateScopeType('course-v1:MIT+DemoX+2024', 'MIT')).toBeNull(); + }); + + it('returns null for a single library scope', () => { + expect(getAggregateScopeType('lib:MIT:demo', 'MIT')).toBeNull(); + }); + + it('returns null for an org-wide scope when the org is unknown', () => { + expect(getAggregateScopeType('course-v1:MIT+*')).toBeNull(); + }); + + it('returns null when the scope belongs to a different org', () => { + expect(getAggregateScopeType('course-v1:MIT+*', 'HarvardX')).toBeNull(); + }); +}); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 0d97aae6..79ad6e86 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -2,6 +2,7 @@ export const CONTEXT_TYPES = { LIBRARY: 'library', COURSE: 'course', + GLOBAL: 'global', } as const; export type ContextType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES]; @@ -20,6 +21,7 @@ export const getOrgAggregateScopeKey = (contextType: ContextType, orgSlug: strin const PLATFORM_AGGREGATE_SCOPE_KEYS = { [CONTEXT_TYPES.COURSE]: 'course-v1:*', [CONTEXT_TYPES.LIBRARY]: 'lib:*', + [CONTEXT_TYPES.GLOBAL]: '*', }; export const getPlatformAggregateScopeKey = (contextType: ContextType): string => { @@ -31,6 +33,27 @@ export const getPlatformAggregateScopeKey = (contextType: ContextType): string = /** The `org` an assignment carries when it spans every organization. */ export const ALL_ORGS_KEY = '*'; +/** + * The kind of resource a scope points at, read from the scope key itself rather than from + * the role that grants it, so it does not depend on role naming staying conventional. + */ +export const getScopeContextType = (scope: string): ContextType => ( + scope.startsWith('lib') ? CONTEXT_TYPES.LIBRARY : CONTEXT_TYPES.COURSE +); + +/** + * Tells whether a scope is one of the wildcard scopes, and which level it aggregates. + * + * Returns `null` for a scope pointing at a single course or library. The org slug is + * needed to recognize an org-level aggregate, since its key embeds the slug. + */ +export const getAggregateScopeType = (scope: string, org?: string | null): 'platform' | 'org' | null => { + const contextType = getScopeContextType(scope); + if (scope === getPlatformAggregateScopeKey(contextType)) { return 'platform'; } + if (org && scope === getOrgAggregateScopeKey(contextType, org)) { return 'org'; } + return null; +}; + export const DEFAULT_TOAST_DELAY = 5000; export const RETRY_TOAST_DELAY = 120_000; // 2 minutes diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts index be2affd1..4651c5aa 100644 --- a/src/authz-module/messages.ts +++ b/src/authz-module/messages.ts @@ -47,22 +47,22 @@ const messages = defineMessages( 'authz.scope.aggregate.platform.course': { id: 'authz.scope.aggregate.platform.course', defaultMessage: 'All courses on the platform', - description: 'Label for the scope covering every course on the platform', + description: 'Label for the aggregate scope covering every course in the platform (course-v1:* scope).', }, 'authz.scope.aggregate.platform.library': { id: 'authz.scope.aggregate.platform.library', defaultMessage: 'All libraries on the platform', - description: 'Label for the scope covering every library on the platform', + description: 'Label for the aggregate scope covering every library in the platform (lib:* scope).', }, 'authz.scope.aggregate.org.course': { id: 'authz.scope.aggregate.org.course', defaultMessage: 'All courses in this organization', - description: 'Label for the scope covering every course within one organization', + description: 'Label for the aggregate scope covering every course of a single organization (course-v1:+* scope).', }, 'authz.scope.aggregate.org.library': { id: 'authz.scope.aggregate.org.library', defaultMessage: 'All libraries in this organization', - description: 'Label for the scope covering every library within one organization', + description: 'Label for the aggregate scope covering every library of a single organization (lib::* scope).', }, 'authz.team.toast.retry.label': { id: 'authz.team.toast.retry.label', @@ -72,4 +72,21 @@ const messages = defineMessages( }, ); +/** + * Labels for the aggregate (wildcard) scopes, keyed by aggregate type and context type. + * + * Shared so the scope list in the assignment wizard and the scope column in the + * assignments tables name the same scope the same way. + */ +export const AGGREGATE_SCOPE_LABELS = { + platform: { + course: messages['authz.scope.aggregate.platform.course'], + library: messages['authz.scope.aggregate.platform.library'], + }, + org: { + course: messages['authz.scope.aggregate.org.course'], + library: messages['authz.scope.aggregate.org.library'], + }, +}; + export default messages; diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index f1756268..88de3e9a 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -3,9 +3,9 @@ import { useIntl } from '@openedx/frontend-base'; import { Scope } from '@src/types'; import { useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; -import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; +import { CONTEXT_TYPES, getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; import type { ContextType } from '@src/authz-module/constants'; -import moduleMessages from '@src/authz-module/messages'; +import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import messages from '../messages'; import useScopePermissions from './useScopePermissions'; @@ -84,13 +84,11 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) ? intl.formatMessage(messages['wizard.step2.scope.aggregate.description.course']) : intl.formatMessage(messages['wizard.step2.scope.aggregate.description.library']); - const platformAggregateLabel = contextType === 'course' - ? intl.formatMessage(moduleMessages['authz.scope.aggregate.platform.course']) - : intl.formatMessage(moduleMessages['authz.scope.aggregate.platform.library']); + const labelContextType = contextType === 'course' ? CONTEXT_TYPES.COURSE : CONTEXT_TYPES.LIBRARY; - const orgAggregateLabel = contextType === 'course' - ? intl.formatMessage(moduleMessages['authz.scope.aggregate.org.course']) - : intl.formatMessage(moduleMessages['authz.scope.aggregate.org.library']); + const platformAggregateLabel = intl.formatMessage(AGGREGATE_SCOPE_LABELS.platform[labelContextType]); + + const orgAggregateLabel = intl.formatMessage(AGGREGATE_SCOPE_LABELS.org[labelContextType]); const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission) ? { diff --git a/src/authz-module/team-members/AssignedRolesCell.tsx b/src/authz-module/team-members/AssignedRolesCell.tsx index fe574018..76fab906 100644 --- a/src/authz-module/team-members/AssignedRolesCell.tsx +++ b/src/authz-module/team-members/AssignedRolesCell.tsx @@ -2,13 +2,12 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { Business, Person } from '@openedx/paragon/icons'; import { - ALL_ORGS_KEY, CONTEXT_TYPES, getOrgAggregateScopeKey, - getPlatformAggregateScopeKey, MAP_ROLE_KEY_TO_LABEL, + ALL_ORGS_KEY, getAggregateScopeType, getScopeContextType, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; -import moduleMessages from '@src/authz-module/messages'; +import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import messages from './messages'; interface AssignedRolesCellProps { @@ -36,21 +35,12 @@ export const AssignmentSummary = ({ assignment }: AssignmentSummaryProps) => { const { role, scope, scopeDisplayName, org, } = assignment; - const contextType = role?.startsWith('lib') ? CONTEXT_TYPES.LIBRARY : CONTEXT_TYPES.COURSE; - const isLibrary = contextType === CONTEXT_TYPES.LIBRARY; - // An aggregate scope covers every course/library across the platform or within one org, // so it names no single resource and the API sends an empty display name for it. - let scopeText = scopeDisplayName || scope; - if (scope === getPlatformAggregateScopeKey(contextType)) { - scopeText = formatMessage(isLibrary - ? moduleMessages['authz.scope.aggregate.platform.library'] - : moduleMessages['authz.scope.aggregate.platform.course']); - } else if (scope === getOrgAggregateScopeKey(contextType, org)) { - scopeText = formatMessage(isLibrary - ? moduleMessages['authz.scope.aggregate.org.library'] - : moduleMessages['authz.scope.aggregate.org.course']); - } + const aggregateType = getAggregateScopeType(scope, org); + const scopeText = aggregateType + ? formatMessage(AGGREGATE_SCOPE_LABELS[aggregateType][getScopeContextType(scope)]) + : scopeDisplayName || scope; const orgText = org === ALL_ORGS_KEY ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) @@ -64,7 +54,7 @@ export const AssignmentSummary = ({ assignment }: AssignmentSummaryProps) => {
- + {scopeText} diff --git a/src/authz-module/team-members/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/UserAssignmentsSubTable.tsx index 3e6cac9b..3e5581ea 100644 --- a/src/authz-module/team-members/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/UserAssignmentsSubTable.tsx @@ -4,13 +4,12 @@ import { Card, DataTable, Icon } from '@openedx/paragon'; import { ArrowForward, Business } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; import { - ALL_ORGS_KEY, buildAuditUserPath, CONTEXT_TYPES, getOrgAggregateScopeKey, - getPlatformAggregateScopeKey, + ALL_ORGS_KEY, buildAuditUserPath, getAggregateScopeType, getScopeContextType, } from '@src/authz-module/constants'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; -import moduleMessages from '@src/authz-module/messages'; +import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import messages from './messages'; import { RoleBadge } from './AssignedRolesCell'; @@ -27,26 +26,15 @@ const RoleBadgeCell = ({ row: assignmentRow }: AssignmentCellProps) => ( const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { const { formatMessage } = useIntl(); - const { - role, scope, scopeDisplayName, org, - } = assignmentRow.original; - const contextType = role?.startsWith('lib') ? CONTEXT_TYPES.LIBRARY : CONTEXT_TYPES.COURSE; - const isLibrary = contextType === CONTEXT_TYPES.LIBRARY; - - let scopeText = scopeDisplayName || scope; - if (scope === getPlatformAggregateScopeKey(contextType)) { - scopeText = formatMessage(isLibrary - ? moduleMessages['authz.scope.aggregate.platform.library'] - : moduleMessages['authz.scope.aggregate.platform.course']); - } else if (scope === getOrgAggregateScopeKey(contextType, org)) { - scopeText = formatMessage(isLibrary - ? moduleMessages['authz.scope.aggregate.org.library'] - : moduleMessages['authz.scope.aggregate.org.course']); - } + const { scope, scopeDisplayName, org } = assignmentRow.original; + const aggregateType = getAggregateScopeType(scope, org); + const scopeText = aggregateType + ? formatMessage(AGGREGATE_SCOPE_LABELS[aggregateType][getScopeContextType(scope)]) + : scopeDisplayName || scope; return ( - + {scopeText} ); diff --git a/src/authz-module/utils.tsx b/src/authz-module/utils.tsx index eb2cf35d..47d5f2a6 100644 --- a/src/authz-module/utils.tsx +++ b/src/authz-module/utils.tsx @@ -1,7 +1,7 @@ import { Icon } from '@openedx/paragon'; import { FilterList } from '@openedx/paragon/icons'; import { RESOURCE_ICONS } from './components/constants'; -import { DJANGO_MANAGED_ROLES } from './constants'; +import { CONTEXT_TYPES, getScopeContextType } from './constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; /** @@ -34,15 +34,12 @@ export const getCellHeader = (columnId: string, columnTitle: string, filtersAppl }; /** - * Picks the resource icon for a scope from the role that grants it: Django-managed roles - * are platform-wide, `lib*` roles point at libraries, and everything else at courses. + * Picks the resource icon for a scope from the scope key itself. Platform-wide scopes are + * resolved by the caller before this point, since they need the role to be recognised. */ -export const getScopeResourceIcon = (role: string) => { - if (DJANGO_MANAGED_ROLES.includes(role)) { - return RESOURCE_ICONS.GLOBAL; - } - return role?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; -}; +export const getScopeResourceIcon = (scope: string) => ( + getScopeContextType(scope) === CONTEXT_TYPES.LIBRARY ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE +); export const getScopeManageAction = (scope: string) => { if (scope.startsWith('lib')) { From 3d676c0ab0e8be6a209409334518677c97167da7 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 9 Sep 2026 17:55:06 +1000 Subject: [PATCH 05/24] fix: use footer from paragon table --- .../team-members/UserAssignmentsSubTable.tsx | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/authz-module/team-members/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/UserAssignmentsSubTable.tsx index 3e5581ea..8e6d1bfc 100644 --- a/src/authz-module/team-members/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/UserAssignmentsSubTable.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Card, DataTable, Icon } from '@openedx/paragon'; +import { Card, DataTable, Icon, TableFooter } from '@openedx/paragon'; import { ArrowForward, Business } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; import { @@ -99,26 +99,27 @@ const UserAssignmentsSubTable = ({ row }: UserAssignmentsSubTableProps) => { itemCount={assignments.length} > + +
+ + {formatMessage(messages['authz.team.members.subtable.showing.text'], { + shown: String(assignments.length).padStart(2, '0'), + total: String(assignmentCount).padStart(2, '0'), + })} + + {hasMoreAssignments && ( + <> + {/* Reuses the module's vertical `hr` divider (see index.scss). */} +
+ + {formatMessage(messages['authz.team.members.subtable.view.all.roles'])} + + + + )} +
+
- -
- - {formatMessage(messages['authz.team.members.subtable.showing.text'], { - shown: String(assignments.length).padStart(2, '0'), - total: String(assignmentCount).padStart(2, '0'), - })} - - {hasMoreAssignments && ( - <> - {/* Reuses the module's vertical `hr` divider (see index.scss). */} -
- - {formatMessage(messages['authz.team.members.subtable.view.all.roles'])} - - - - )} -
); }; From d35155c648b35d7cfba0d1536f0d1e78c7b953fe Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 9 Sep 2026 17:59:37 +1000 Subject: [PATCH 06/24] refactor: organize team members feature --- src/authz-module/team-members/TeamMembersTable.tsx | 12 ++++++------ .../{ => components}/AssignedRolesCell.tsx | 2 +- .../{ => components}/CollapseRowsOnChange.tsx | 0 .../{ => components}/MoreRolesToggle.tsx | 2 +- .../{ => components}/TeamMemberCells.test.tsx | 0 .../{ => components}/TeamMemberCells.tsx | 2 +- .../TeamMemberViewActionCell.test.tsx | 0 .../{ => components}/TeamMemberViewActionCell.tsx | 0 .../{ => components}/UserAssignmentsSubTable.tsx | 8 +++++--- 9 files changed, 14 insertions(+), 12 deletions(-) rename src/authz-module/team-members/{ => components}/AssignedRolesCell.tsx (98%) rename src/authz-module/team-members/{ => components}/CollapseRowsOnChange.tsx (100%) rename src/authz-module/team-members/{ => components}/MoreRolesToggle.tsx (98%) rename src/authz-module/team-members/{ => components}/TeamMemberCells.test.tsx (100%) rename src/authz-module/team-members/{ => components}/TeamMemberCells.tsx (97%) rename src/authz-module/team-members/{ => components}/TeamMemberViewActionCell.test.tsx (100%) rename src/authz-module/team-members/{ => components}/TeamMemberViewActionCell.tsx (100%) rename src/authz-module/team-members/{ => components}/UserAssignmentsSubTable.tsx (97%) diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index fa5dd198..957288fb 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -21,12 +21,12 @@ import { useTeamMembersAssignments } from '@src/authz-module/data/hooks'; import { MAX_INLINE_ASSIGNMENTS, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; import messages from './messages'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; -import AssignedRolesCell from './AssignedRolesCell'; -import { EmailCell, NameCell } from './TeamMemberCells'; -import CollapseRowsOnChange from './CollapseRowsOnChange'; -import MoreRolesToggle from './MoreRolesToggle'; -import UserAssignmentsSubTable from './UserAssignmentsSubTable'; -import { createTeamMemberViewActionCell } from './TeamMemberViewActionCell'; +import AssignedRolesCell from './components/AssignedRolesCell'; +import { EmailCell, NameCell } from './components/TeamMemberCells'; +import CollapseRowsOnChange from './components/CollapseRowsOnChange'; +import MoreRolesToggle from './components/MoreRolesToggle'; +import UserAssignmentsSubTable from './components/UserAssignmentsSubTable'; +import { createTeamMemberViewActionCell } from './components/TeamMemberViewActionCell'; interface TeamMembersTableProps { presetScope?: string; diff --git a/src/authz-module/team-members/AssignedRolesCell.tsx b/src/authz-module/team-members/components/AssignedRolesCell.tsx similarity index 98% rename from src/authz-module/team-members/AssignedRolesCell.tsx rename to src/authz-module/team-members/components/AssignedRolesCell.tsx index 76fab906..513d942c 100644 --- a/src/authz-module/team-members/AssignedRolesCell.tsx +++ b/src/authz-module/team-members/components/AssignedRolesCell.tsx @@ -8,7 +8,7 @@ import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; -import messages from './messages'; +import messages from '../messages'; interface AssignedRolesCellProps { row: { original: TeamMember }; diff --git a/src/authz-module/team-members/CollapseRowsOnChange.tsx b/src/authz-module/team-members/components/CollapseRowsOnChange.tsx similarity index 100% rename from src/authz-module/team-members/CollapseRowsOnChange.tsx rename to src/authz-module/team-members/components/CollapseRowsOnChange.tsx diff --git a/src/authz-module/team-members/MoreRolesToggle.tsx b/src/authz-module/team-members/components/MoreRolesToggle.tsx similarity index 98% rename from src/authz-module/team-members/MoreRolesToggle.tsx rename to src/authz-module/team-members/components/MoreRolesToggle.tsx index c627f381..6a5e259a 100644 --- a/src/authz-module/team-members/MoreRolesToggle.tsx +++ b/src/authz-module/team-members/components/MoreRolesToggle.tsx @@ -4,7 +4,7 @@ import { DataTableContext } from '@openedx/paragon'; import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; import type { TeamMember } from '@src/types'; -import messages from './messages'; +import messages from '../messages'; interface DataTableInstance { state?: { diff --git a/src/authz-module/team-members/TeamMemberCells.test.tsx b/src/authz-module/team-members/components/TeamMemberCells.test.tsx similarity index 100% rename from src/authz-module/team-members/TeamMemberCells.test.tsx rename to src/authz-module/team-members/components/TeamMemberCells.test.tsx diff --git a/src/authz-module/team-members/TeamMemberCells.tsx b/src/authz-module/team-members/components/TeamMemberCells.tsx similarity index 97% rename from src/authz-module/team-members/TeamMemberCells.tsx rename to src/authz-module/team-members/components/TeamMemberCells.tsx index 1180119b..996a245d 100644 --- a/src/authz-module/team-members/TeamMemberCells.tsx +++ b/src/authz-module/team-members/components/TeamMemberCells.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import type { TeamMember } from '@src/types'; -import messages from './messages'; +import messages from '../messages'; interface NameCellProps { row: { original: TeamMember }; diff --git a/src/authz-module/team-members/TeamMemberViewActionCell.test.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx similarity index 100% rename from src/authz-module/team-members/TeamMemberViewActionCell.test.tsx rename to src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx diff --git a/src/authz-module/team-members/TeamMemberViewActionCell.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx similarity index 100% rename from src/authz-module/team-members/TeamMemberViewActionCell.tsx rename to src/authz-module/team-members/components/TeamMemberViewActionCell.tsx diff --git a/src/authz-module/team-members/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx similarity index 97% rename from src/authz-module/team-members/UserAssignmentsSubTable.tsx rename to src/authz-module/team-members/components/UserAssignmentsSubTable.tsx index 8e6d1bfc..b39de922 100644 --- a/src/authz-module/team-members/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx @@ -1,6 +1,8 @@ import { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Card, DataTable, Icon, TableFooter } from '@openedx/paragon'; +import { + Card, DataTable, Icon, TableFooter, +} from '@openedx/paragon'; import { ArrowForward, Business } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; import { @@ -10,7 +12,7 @@ import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; -import messages from './messages'; +import messages from '../messages'; import { RoleBadge } from './AssignedRolesCell'; interface UserAssignmentsSubTableProps { @@ -118,7 +120,7 @@ const UserAssignmentsSubTable = ({ row }: UserAssignmentsSubTableProps) => { )}
- + ); From e3a6d1687b493ad41732335ab9f8d33b286073c9 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 16:05:38 +1000 Subject: [PATCH 07/24] refactor: AssignedRolesCell unique component --- src/authz-module/index.scss | 4 + .../components/AssignedRolesCell.tsx | 73 +++++++++---------- .../components/TeamMemberCells.tsx | 20 +++-- .../components/UserAssignmentsSubTable.tsx | 4 +- src/authz-module/team-members/messages.ts | 14 ++-- 5 files changed, 60 insertions(+), 55 deletions(-) diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index b2fbd32c..ea1e6a87 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -164,6 +164,9 @@ */ .authz-assigned-roles .authz-role-badge { min-width: 11rem; + // The row is muted so the bare "in" reads as a connector; the badge is content. + color: var(--pgn-color-gray-700); + margin-right: var(--pgn-spacing-spacer-3); } .authz-scope-cell { @@ -172,6 +175,7 @@ &__name { max-width: 22rem; // ~35 characters + color: var(--pgn-color-gray-700); } &__org { diff --git a/src/authz-module/team-members/components/AssignedRolesCell.tsx b/src/authz-module/team-members/components/AssignedRolesCell.tsx index 513d942c..3416cd4c 100644 --- a/src/authz-module/team-members/components/AssignedRolesCell.tsx +++ b/src/authz-module/team-members/components/AssignedRolesCell.tsx @@ -1,4 +1,4 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { Business, Person } from '@openedx/paragon/icons'; import { @@ -6,7 +6,7 @@ import { } from '@src/authz-module/constants'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; -import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import type { TeamMember } from '@src/types'; import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import messages from '../messages'; @@ -14,10 +14,6 @@ interface AssignedRolesCellProps { row: { original: TeamMember }; } -interface AssignmentSummaryProps { - assignment: TeamMemberAssignment; -} - /** The role pill: a light rounded block with the person icon, not a Paragon Chip. */ export const RoleBadge = ({ role }: { role: string }) => (
@@ -27,11 +23,21 @@ export const RoleBadge = ({ role }: { role: string }) => ( ); /** - * Renders one assignment as "[Role] In " with the organization on a subline. - * Shared by the collapsed row and the nested sub-table so both read identically. + * Collapsed-row cell: the user's first assignment, as "[Role] In " with the + * organization on a subline. + * + * Renders `assignments[0]` exactly as the API returns it — the sub-table's first row must + * match it, so no client-side reordering happens here. The badge stays visible while the + * row is expanded, for visual continuity. */ -export const AssignmentSummary = ({ assignment }: AssignmentSummaryProps) => { +const AssignedRolesCell = ({ row }: AssignedRolesCellProps) => { const { formatMessage } = useIntl(); + const [assignment] = row.original.assignments ?? []; + + if (!assignment) { + return null; + } + const { role, scope, scopeDisplayName, org, } = assignment; @@ -47,38 +53,27 @@ export const AssignmentSummary = ({ assignment }: AssignmentSummaryProps) => { : org; return ( -
- - - {formatMessage(messages['authz.team.members.table.assigned.roles.connector'])} - -
- - - {scopeText} - - - - {orgText} - -
+
+ , + scope: ( +
+ + + {scopeText} + + + + {orgText} + +
+ ), + }} + />
); }; -/** - * Collapsed-row cell. Renders `assignments[0]` exactly as the API returns it — the - * sub-table's first row must match it, so no client-side reordering happens here. The - * badge stays visible while the row is expanded, for visual continuity. - */ -const AssignedRolesCell = ({ row }: AssignedRolesCellProps) => { - const [firstAssignment] = row.original.assignments ?? []; - - if (!firstAssignment) { - return null; - } - - return ; -}; - export default AssignedRolesCell; diff --git a/src/authz-module/team-members/components/TeamMemberCells.tsx b/src/authz-module/team-members/components/TeamMemberCells.tsx index 996a245d..51ba8d0d 100644 --- a/src/authz-module/team-members/components/TeamMemberCells.tsx +++ b/src/authz-module/team-members/components/TeamMemberCells.tsx @@ -1,9 +1,12 @@ -import { useContext } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { useContext, type ReactNode } from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import type { TeamMember } from '@src/types'; import messages from '../messages'; +/** Marks the row belonging to the signed-in user. */ +const Marker = (chunks: ReactNode[]) => {chunks}; + interface NameCellProps { row: { original: TeamMember }; } @@ -18,17 +21,20 @@ interface EmailCellProps { * signed-in user is marked so they can find themselves in the list. */ export const NameCell = ({ row }: NameCellProps) => { - const intl = useIntl(); const { authenticatedUser } = useContext(AppContext); const { username = '' } = row.original; const isCurrentUser = username === authenticatedUser?.username; return ( - {username} - {isCurrentUser && ( - {intl.formatMessage(messages['authz.table.username.current'])} - )} + {isCurrentUser + ? ( + + ) + : username} ); }; diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx index b39de922..64f39e68 100644 --- a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx @@ -36,7 +36,7 @@ const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { return ( - + {scopeText} ); @@ -53,7 +53,7 @@ const OrgIconCell = ({ row: assignmentRow }: AssignmentCellProps) => { return ( - + {orgText} ); diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts index ba7da908..e2dce965 100644 --- a/src/authz-module/team-members/messages.ts +++ b/src/authz-module/team-members/messages.ts @@ -6,9 +6,9 @@ const messages = defineMessages({ defaultMessage: 'Username', description: 'Team members table username column header', }, - 'authz.table.username.current': { - id: 'authz.table.username.current', - defaultMessage: '(Me)', + 'authz.team.members.table.username.current': { + id: 'authz.team.members.table.username.current', + defaultMessage: '{username} (Me)', description: 'Indicates the current user in the team members table', }, 'authz.team.members.table.column.email.title': { @@ -41,10 +41,10 @@ const messages = defineMessages({ defaultMessage: 'Assigned roles', description: 'Team members table assigned roles column header', }, - 'authz.team.members.table.assigned.roles.connector': { - id: 'authz.team.members.table.assigned.roles.connector', - defaultMessage: 'In', - description: 'Connector word placed between a role badge and the scope it applies to, e.g. "Course Admin In Introduction to Data Analysis"', + 'authz.team.members.table.assigned.roles': { + id: 'authz.team.members.table.assigned.roles', + defaultMessage: '{role} in {scope}', + description: 'One role assignment on a team member row, e.g. "Course Admin in Introduction to Data Analysis". {role} is the role badge and {scope} the course or library it applies to, so both the order and the connecting word can be changed per language.', }, 'authz.team.members.table.more.roles': { id: 'authz.team.members.table.more.roles', From 82260152ceb19265045b17488836f4a7c3519341 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 11:17:39 +1000 Subject: [PATCH 08/24] fix: table colum widths fixed only for md-xl screens --- src/authz-module/index.scss | 81 ++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index ea1e6a87..f8c4b38a 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -90,40 +90,85 @@ summary, and just enough for the toggle and the action icon. */ &.team-members-table { - .pgn__data-table { - table-layout: fixed; + /* + Roomier rows. Paragon's 8px is tight here because the assigned-roles cell stacks a + scope name over its organization, so every row is two lines tall. + */ + th, + td { + padding-top: var(--pgn-spacing-spacer-3); + padding-bottom: var(--pgn-spacing-spacer-3); } - th:nth-child(1) { width: 16%; } // Username, ~24 characters - th:nth-child(2) { width: 18%; } // Email, ~28 characters - th:nth-child(3) { width: 46%; } // Assigned roles: badge + scope + org subline - th:nth-child(4) { width: 13%; } // "+N more roles" toggle - th:nth-child(5) { width: 7%; } // Actions - // The toggle reads as one phrase; wrapping it mid-label looks broken. td:nth-child(4) { white-space: nowrap; } - td { - overflow: hidden; - } + /* + Column sizing, from `md` up only. Auto-layout hands width to the short Username and + Email values and starves "Assigned roles", cutting the scope name off far earlier + than the design allows, so the columns are pinned explicitly. Widths follow the + design's character budget: ~24 for username, ~28 for email, the bulk to the role + summary, and just enough for the toggle and the action icon. - // The breakdown sizes its own three columns; it must not inherit the widths above. - .team-members-table__subtable { + Below `md` these percentages would squeeze the toggle and action columns to a few + pixels, so the table falls back to content sizing and scrolls sideways inside + Paragon's `.pgn__data-table-container`, which is already `overflow-x: auto`. + */ + @media (--pgn-size-breakpoint-min-width-md) { .pgn__data-table { - table-layout: auto; + table-layout: fixed; } - th:nth-child(1), - th:nth-child(2), - th:nth-child(3) { - width: auto; + th:nth-child(1) { width: 16%; } // Username, ~24 characters + th:nth-child(2) { width: 18%; } // Email, ~28 characters + th:nth-child(3) { width: 46%; } // Assigned roles: badge + scope + org subline + th:nth-child(4) { width: 13%; } // "+N more roles" toggle + th:nth-child(5) { width: 7%; } // Actions + + td { + overflow: hidden; + } + + // The breakdown sizes its own three columns; it must not inherit the widths above. + .team-members-table__subtable { + .pgn__data-table { + table-layout: auto; + } + + th:nth-child(1), + th:nth-child(2), + th:nth-child(3) { + width: auto; + } + } + } + + // The breakdown is one line per row, so it keeps the tighter default rhythm. + .team-members-table__subtable { + th, + td { + padding-top: var(--pgn-spacing-spacer-2); + padding-bottom: var(--pgn-spacing-spacer-2); } } } .team-members-table__subtable { + /* + The module turns `hr` into a horizontal rule below `sm`, for the page actions that + stack there. This one separates two labels in a footer that stays a row at every + width, so it keeps the vertical treatment — and is sized to the small text beside + it rather than to the page-header actions. + */ + hr { + border-right: var(--pgn-size-border-width) solid var(--pgn-color-border); + border-top: none; + height: 1rem; + width: 0; + } + // The breakdown sits inside a padded container of its own, so the module's blanket // 1rem on every data-table wrapper would double it up. .pgn__data-table-wrapper { From d9e827542c35203c8bc6fa6b623b51e37cfd141e Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 11:37:14 +1000 Subject: [PATCH 09/24] test: increase coverage --- .../components/TableCells.test.tsx | 45 ++++++++ .../team-members/TeamMembersTable.test.tsx | 35 ++++++ .../components/AssignedRolesCell.test.tsx | 108 ++++++++++++++++++ .../UserAssignmentsSubTable.test.tsx | 101 ++++++++++++++++ src/authz-module/utils.test.tsx | 23 +++- 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/authz-module/team-members/components/AssignedRolesCell.test.tsx create mode 100644 src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index db50145a..7191c6fd 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -184,6 +184,51 @@ describe('TableCells Components', () => { expect(screen.queryByText('course')).not.toBeInTheDocument(); }); + const scopeProps = ( + original: { role: string; scope: string; org: string }, + ) => ({ + value: 'unused', + row: { id: '0', original: { permissionCount: 1, ...original } }, + column: { id: 'scope' }, + }); + + it('displays "Global" for the platform-wide superadmin scope', () => { + renderWrapper(); + + expect(screen.getByText('Global')).toBeInTheDocument(); + }); + + it('names a platform-wide course scope instead of showing its wildcard key', () => { + renderWrapper(); + + expect(screen.getByText('All courses on the platform')).toBeInTheDocument(); + expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); + }); + + it('names a platform-wide library scope', () => { + renderWrapper(); + + expect(screen.getByText('All libraries on the platform')).toBeInTheDocument(); + }); + + it('names an organization-wide course scope', () => { + renderWrapper(); + + expect(screen.getByText('All courses in this organization')).toBeInTheDocument(); + }); + + it('names an organization-wide library scope', () => { + renderWrapper(); + + expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + }); + + it('shows the scope key when a wildcard belongs to a different organization', () => { + renderWrapper(); + + expect(screen.getByText('course-v1:MathDept+*')).toBeInTheDocument(); + }); + it('displays the actual scope value for non-Django roles', () => { const props = { value: 'Course Scope', diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 72540ad1..5681a9dd 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -360,6 +360,41 @@ describe('TeamMembersTable', () => { expect(screen.queryByRole('link', { name: /View all roles/ })).not.toBeInTheDocument(); }); + it('keeps only one breakdown open, collapsing the previous row', async () => { + const user = userEvent.setup(); + // Both users need something to reveal, so either row can be expanded. + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: mockedTeamMembers.data!.results.map((member) => ({ + ...member, + assignmentCount: 5, + assignments: [courseAssignment], + })), + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getAllByText('+4 more roles')).toHaveLength(2); + }); + + await user.click(screen.getAllByText('+4 more roles')[0]); + await waitFor(() => { + expect(screen.getAllByText('Hide roles')).toHaveLength(1); + }); + + await user.click(screen.getByText('+4 more roles')); + + // The second row took over: exactly one breakdown is open, and it is not the first. + await waitFor(() => { + expect(screen.getAllByText('Hide roles')).toHaveLength(1); + }); + const toggles = screen.getAllByText(/Hide roles|more roles?$/); + expect(toggles[0]).toHaveTextContent('+4 more roles'); + expect(toggles[1]).toHaveTextContent('Hide roles'); + }); + it('collapses expanded rows when a filter is applied', async () => { const user = userEvent.setup(); mockApiResponses(); diff --git a/src/authz-module/team-members/components/AssignedRolesCell.test.tsx b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx new file mode 100644 index 00000000..20514982 --- /dev/null +++ b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx @@ -0,0 +1,108 @@ +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import AssignedRolesCell from './AssignedRolesCell'; + +const courseAssignment: TeamMemberAssignment = { + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeDisplayName: 'Open edX Demo Course', + permissionCount: 27, +}; + +const rowFor = (assignments: TeamMemberAssignment[], overrides: Partial = {}) => ({ + row: { + original: { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: assignments.length, + assignments, + ...overrides, + }, + }, +}); + +describe('AssignedRolesCell', () => { + it('names the role, the scope it applies to, and the organization', () => { + renderWrapper(); + + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(screen.getByText('Open edX Demo Course')).toBeInTheDocument(); + expect(screen.getByText('OpenedX')).toBeInTheDocument(); + }); + + it('shows only the first assignment when the user has several', () => { + const libraryAssignment: TeamMemberAssignment = { + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + scopeDisplayName: 'Computer Science Problems', + permissionCount: 11, + }; + renderWrapper(); + + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(screen.queryByText('Library Admin')).not.toBeInTheDocument(); + }); + + it('falls back to the scope id when the API resolved no display name', () => { + renderWrapper(); + + expect(screen.getByText('course-v1:OpenedX+DemoX+DemoCourse')).toBeInTheDocument(); + }); + + it('names a platform-wide course scope instead of showing its wildcard key', () => { + renderWrapper(); + + expect(screen.getByText('All courses on the platform')).toBeInTheDocument(); + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); + }); + + it('names a platform-wide library scope', () => { + renderWrapper(); + + expect(screen.getByText('All libraries on the platform')).toBeInTheDocument(); + }); + + it('names an organization-wide course scope, keeping the organization on its own line', () => { + renderWrapper(); + + expect(screen.getByText('All courses in this organization')).toBeInTheDocument(); + expect(screen.getByText('MathDept')).toBeInTheDocument(); + }); + + it('names an organization-wide library scope', () => { + renderWrapper(); + + expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + }); + + it('shows the raw role key when the API sends a role the UI has no label for', () => { + renderWrapper(); + + expect(screen.getByText('course_unmapped')).toBeInTheDocument(); + }); + + it('renders nothing for a user whose assignments were not returned', () => { + const { container } = renderWrapper( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx new file mode 100644 index 00000000..9751c180 --- /dev/null +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx @@ -0,0 +1,101 @@ +import { screen, within } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import UserAssignmentsSubTable from './UserAssignmentsSubTable'; + +const courseAssignment: TeamMemberAssignment = { + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeDisplayName: 'Open edX Demo Course', + permissionCount: 27, +}; + +const libraryAssignment: TeamMemberAssignment = { + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + scopeDisplayName: 'Computer Science Problems', + permissionCount: 11, +}; + +const rowFor = (assignments: TeamMemberAssignment[], assignmentCount = assignments.length) => ({ + row: { + original: { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount, + assignments, + } as TeamMember, + }, +}); + +describe('UserAssignmentsSubTable', () => { + it('lists one row per returned assignment, in the order the API sent them', () => { + renderWrapper(); + + const rows = screen.getAllByRole('row').slice(1); // drop the header row + expect(rows).toHaveLength(2); + expect(within(rows[0]).getByText('Course Staff')).toBeInTheDocument(); + expect(within(rows[1]).getByText('Library Admin')).toBeInTheDocument(); + }); + + it('names each scope and organization', () => { + renderWrapper(); + + expect(screen.getByText('Open edX Demo Course')).toBeInTheDocument(); + expect(screen.getByText('OpenedX')).toBeInTheDocument(); + }); + + it('falls back to the scope id when the API resolved no display name', () => { + renderWrapper(); + + expect(screen.getByText('course-v1:OpenedX+DemoX+DemoCourse')).toBeInTheDocument(); + }); + + it('names wildcard scopes instead of showing their keys', () => { + renderWrapper(); + + expect(screen.getByText('All courses on the platform')).toBeInTheDocument(); + expect(screen.getByText('All libraries on the platform')).toBeInTheDocument(); + expect(screen.getByText('All courses in this organization')).toBeInTheDocument(); + expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + expect(screen.getAllByText('All Organizations')).toHaveLength(2); + expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); + }); + + it('reports how many of the user total are listed', () => { + renderWrapper(); + + expect(screen.getByText('Showing 02 of 07')).toBeInTheDocument(); + }); + + it('offers the audit page when the listing is truncated', () => { + renderWrapper(); + + expect(screen.getByRole('link', { name: /View all roles/ })) + .toHaveAttribute('href', '/authz/user/johndoe'); + }); + + it('omits the audit link when every role is already listed', () => { + renderWrapper(); + + expect(screen.getByText('Showing 02 of 02')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /View all roles/ })).not.toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/utils.test.tsx b/src/authz-module/utils.test.tsx index 743bc5d4..3e0fbb66 100644 --- a/src/authz-module/utils.test.tsx +++ b/src/authz-module/utils.test.tsx @@ -1,6 +1,9 @@ import { screen } from '@testing-library/react'; import { initializeMocks, renderWrapper } from '@src/testUtils'; -import { getCellHeader, getScopeManageAction, getScopeManageActionPermission } from './utils'; +import { + getCellHeader, getScopeManageAction, getScopeManageActionPermission, getScopeResourceIcon, +} from './utils'; +import { RESOURCE_ICONS } from './components/constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; const renderCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => { @@ -81,6 +84,24 @@ describe('utils', () => { }); }); + describe('getScopeResourceIcon', () => { + it('picks the library icon for a library scope', () => { + expect(getScopeResourceIcon('lib:MathDept:ALGEBRA')).toBe(RESOURCE_ICONS.LIBRARY); + }); + + it('picks the library icon for an aggregate library scope', () => { + expect(getScopeResourceIcon('lib:*')).toBe(RESOURCE_ICONS.LIBRARY); + }); + + it('picks the course icon for a course scope', () => { + expect(getScopeResourceIcon('course-v1:MathDept+ALG101+2026')).toBe(RESOURCE_ICONS.COURSE); + }); + + it('picks the course icon for an aggregate course scope', () => { + expect(getScopeResourceIcon('course-v1:*')).toBe(RESOURCE_ICONS.COURSE); + }); + }); + describe('getScopeManageAction', () => { it('returns MANAGE_LIBRARY_TEAM for library scopes', () => { expect(getScopeManageAction('lib:testorg:library-123')).toBe( From 023eadfe950da582843ff8e1f1a507f834d3a706 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 12:13:17 +1000 Subject: [PATCH 10/24] revert: keep Visibility icon change out of the scope --- .../roles-permissions/course/constants.ts | 26 +++++++++---------- .../roles-permissions/library/constants.ts | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/authz-module/roles-permissions/course/constants.ts b/src/authz-module/roles-permissions/course/constants.ts index 0a288c0d..13d0be97 100644 --- a/src/authz-module/roles-permissions/course/constants.ts +++ b/src/authz-module/roles-permissions/course/constants.ts @@ -10,7 +10,7 @@ import { Download, DrawShapes, CheckCircle, - Visibility, + RemoveRedEye, Plus, EditOutline, DownloadDone, @@ -148,7 +148,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_access_content', description: 'See the course in the Studio home and access the course outline in read-only mode. Includes the "View Live" option to preview the course as a learner in the LMS.', label: 'View course', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE, @@ -176,7 +176,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_library_updates', description: 'View pending updates from content libraries linked to this course.', label: 'View library updates', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_LIBRARY_UPDATES, @@ -191,7 +191,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_updates_handouts', description: 'See course announcements and handouts visible to learners.', label: 'View course updates', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, @@ -206,7 +206,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_pages_resources', description: 'See the Pages & Resources section in Studio.', label: 'View pages & resources', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, @@ -221,7 +221,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_files', description: 'See the list of files and assets uploaded to the course.', label: 'View files', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, @@ -250,7 +250,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_schedule_details', description: 'See the course schedule (start and end dates, enrollment dates, and pacing settings) and course details (summary, pacing, and prerequisites).', label: 'View schedule & details', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, @@ -272,7 +272,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_grading', description: 'See the grading configuration for the course, including assignment types and grading scale.', label: 'View grading settings', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, @@ -287,7 +287,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_team_group', description: 'See the list of users with a role assigned to this course.', label: 'View course team', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, @@ -301,7 +301,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_team_group', description: 'See the list of content groups and their configurations for this course.', label: 'View group configurations', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, @@ -323,7 +323,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_advanced_certificates', description: 'Access the Advanced Settings page in Studio. This covers a wide range of technical course configurations, including proctoring, timed exams, LTI tools, enrollment limits, and custom display options.', label: 'View advanced settings', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, @@ -337,7 +337,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_advanced_certificates', description: 'See the course certificate settings.', label: 'View certificates', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, @@ -374,7 +374,7 @@ export const coursePermissions: PermissionMetadata[] = [ resource: 'course_other', description: 'See the course launch checklist in Studio.', label: 'View checklists', - icon: Visibility, + icon: RemoveRedEye, }, ]; diff --git a/src/authz-module/roles-permissions/library/constants.ts b/src/authz-module/roles-permissions/library/constants.ts index adfb7404..dc15f162 100644 --- a/src/authz-module/roles-permissions/library/constants.ts +++ b/src/authz-module/roles-permissions/library/constants.ts @@ -3,7 +3,7 @@ import { } from '@src/types'; import { Group, CollectionsBookmark, Notes, AutoAwesomeMosaic, - Visibility, + RemoveRedEye, Settings, DownloadDone, Plus, @@ -91,7 +91,7 @@ export const libraryPermissions: PermissionMetadata[] = [ resource: 'library', label: 'View', description: 'See the library in Studio and access its content in read-only mode.', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, @@ -133,7 +133,7 @@ export const libraryPermissions: PermissionMetadata[] = [ resource: 'library_team', label: 'View', description: 'See the list of users with a role assigned to this library.', - icon: Visibility, + icon: RemoveRedEye, }, { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, From ca289c4e5067bf3c7c74a66244d9228c6ec60217 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 14:39:42 +1000 Subject: [PATCH 11/24] refactor: use barriel for team-members --- src/authz-module/authz-home/index.tsx | 2 +- src/authz-module/team-members/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 src/authz-module/team-members/index.ts diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx index 15250122..2793344a 100644 --- a/src/authz-module/authz-home/index.tsx +++ b/src/authz-module/authz-home/index.tsx @@ -1,7 +1,7 @@ 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 RolesPermissions from '@src/authz-module/roles-permissions/RolesPermissions'; import AuthZLayout from '@src/authz-module/components/AuthZLayout'; diff --git a/src/authz-module/team-members/index.ts b/src/authz-module/team-members/index.ts new file mode 100644 index 00000000..adbf500d --- /dev/null +++ b/src/authz-module/team-members/index.ts @@ -0,0 +1 @@ +export { default as TeamMembersTable } from './TeamMembersTable'; From 69a86f205c83916544cbaee4f15b2b50b8375a33 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 18:08:51 +1000 Subject: [PATCH 12/24] fix: restore the error boundary for 403/401 error --- src/authz-module/authz-home/index.test.tsx | 53 +++++++++++++++++++ src/authz-module/authz-home/index.tsx | 24 +++++++++ .../team-members/TeamMembersTable.test.tsx | 13 +++++ .../team-members/TeamMembersTable.tsx | 20 ++++++- src/components/LoadingPage.tsx | 29 ++++++---- src/components/messages.ts | 11 ++++ 6 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 src/components/messages.ts diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index 4d486c4a..0ac7ab6f 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -3,6 +3,8 @@ import { useTeamMembersAssignments, useOrgs, useScopes } from '@src/authz-module 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'; @@ -14,6 +16,10 @@ jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({ }), })); +jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ + useViewTeamPermissions: jest.fn(), +})); + jest.mock('@src/authz-module/data/hooks', () => ({ useTeamMembersAssignments: jest.fn(), useOrgs: jest.fn(), @@ -46,6 +52,13 @@ const renderAuthzHome = () => renderWithAllProviders( describe('AuthzHome', () => { beforeEach(() => { + // Call history is asserted on below, so it must not carry over between cases. + jest.clearAllMocks(); + (useViewTeamPermissions as jest.Mock).mockReturnValue({ + isCourseViewAllowed: true, + isLibraryViewAllowed: true, + isLoading: false, + }); (useTeamMembersAssignments as jest.Mock).mockReturnValue(emptyResponse); (useOrgs as jest.Mock).mockReturnValue(emptyResponse); (useScopes as jest.Mock).mockReturnValue(emptyScopesResponse); @@ -77,6 +90,46 @@ 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', () => { + (useViewTeamPermissions as jest.Mock).mockReturnValue({ + isCourseViewAllowed: false, + isLibraryViewAllowed: false, + 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', () => { + (useViewTeamPermissions as jest.Mock).mockReturnValue({ + isCourseViewAllowed: false, + isLibraryViewAllowed: false, + isLoading: true, + }); + + 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', () => { + (useViewTeamPermissions as jest.Mock).mockReturnValue({ + isCourseViewAllowed: false, + 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(); diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx index 2793344a..200c9e17 100644 --- a/src/authz-module/authz-home/index.tsx +++ b/src/authz-module/authz-home/index.tsx @@ -3,6 +3,9 @@ import { Tab, Tabs } from '@openedx/paragon'; import { useLocation, useSearchParams } from 'react-router-dom'; 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'; @@ -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 ; + } + + if (!isCourseViewAllowed && !isLibraryViewAllowed) { + throw new Error(CustomErrors.NO_ACCESS); + } + return ( { }); }); + it('announces a failure once, not once per effect pass', () => { + const serverError = Object.assign(new Error('Boom'), { + customAttributes: { httpErrorStatus: 500 }, + }); + mockApiResponses({ + ...mockedTeamMembers, isLoading: false, error: serverError, data: undefined, + }); + + renderTable(); + + expect(screen.getAllByText(/We're experiencing technical difficulties./)).toHaveLength(1); + }); + it('handles empty data gracefully', async () => { mockApiResponses({ data: { diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index 957288fb..22685344 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -18,6 +18,7 @@ import TableControlBar from '@src/authz-module/components/TableControlBar/TableC import { getCellHeader } from '@src/authz-module/utils'; import { useTeamMembersAssignments } from '@src/authz-module/data/hooks'; +import type { GetTeamMembersAssignmentsResponse } from '@src/authz-module/data/api'; import { MAX_INLINE_ASSIGNMENTS, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; import messages from './messages'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; @@ -32,6 +33,17 @@ interface TeamMembersTableProps { presetScope?: string; } +const toastedErrors = new WeakSet(); + +/** + * Stable stand-in for a query that has not resolved. A fresh object literal here would + * hand `DataTable` a new `data` array on every render, and react-table resets its + * expanded-row state whenever `data` changes identity. + */ +const EMPTY_ASSIGNMENTS: GetTeamMembersAssignmentsResponse = { + results: [], count: 0, next: null, previous: null, +}; + // Org, scope and role stay in the column set — TableControlBar derives its filter // controls from the columns — but are hidden, since the design surfaces them inside // each user's role breakdown instead of as top-level columns. They carry no accessor @@ -66,7 +78,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { }, [isCourseViewAllowed, querySettings]); const { - data: { results: teamMembers, count } = { results: [], count: 0 }, + data: { results: teamMembers, count } = EMPTY_ASSIGNMENTS, isLoading: isLoadingTeamMembers, error, refetch, @@ -76,8 +88,12 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; + /** + * Only transient failures reach here. + */ useEffect(() => { - if (error) { + if (error && !toastedErrors.has(error)) { + toastedErrors.add(error); showErrorToast(error, refetch); } }, [error, showErrorToast, refetch]); diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index fabd8cc4..bd9519c6 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -1,14 +1,23 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; import { Spinner, Container } from '@openedx/paragon'; +import messages from './messages'; -const LoadingPage = () => ( - - - -); +/** + * Full-page loading state. + */ +const LoadingPage = () => { + const intl = useIntl(); + + return ( + + + + ); +}; export default LoadingPage; diff --git a/src/components/messages.ts b/src/components/messages.ts new file mode 100644 index 00000000..03e8c0bd --- /dev/null +++ b/src/components/messages.ts @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'loading.page.screenreader.text': { + id: 'loading.page.screenreader.text', + defaultMessage: 'Loading', + description: 'Announced by screen readers while a full-page loading spinner is shown.', + }, +}); + +export default messages; From 30ae3a150b7b8bf32574b46e14b75b480ed844b5 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 16:45:36 +1000 Subject: [PATCH 13/24] refactor: view button and remove unecesary component --- .../team-members/TeamMembersTable.test.tsx | 23 ----------- .../team-members/TeamMembersTable.tsx | 10 +---- .../components/CollapseRowsOnChange.tsx | 39 ------------------- .../TeamMemberViewActionCell.test.tsx | 26 ++++++++++--- .../components/TeamMemberViewActionCell.tsx | 17 ++++---- 5 files changed, 29 insertions(+), 86 deletions(-) delete mode 100644 src/authz-module/team-members/components/CollapseRowsOnChange.tsx diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 4a89193f..25cad311 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -395,29 +395,6 @@ describe('TeamMembersTable', () => { expect(toggles[1]).toHaveTextContent('Hide roles'); }); - it('collapses expanded rows when a filter is applied', async () => { - const user = userEvent.setup(); - mockApiResponses(); - renderTable(); - await waitFor(() => { - expect(screen.getByText('+9 more roles')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('+9 more roles')); - await waitFor(() => { - expect(screen.getByText('Hide roles')).toBeInTheDocument(); - }); - - const orgFilter = screen.getByRole('button', { name: /Organization/ }); - await user.click(orgFilter); - const option = await screen.findByText('Organization 1'); - await user.click(option); - - await waitFor(() => { - expect(screen.queryByText('Hide roles')).not.toBeInTheDocument(); - }); - }); - it('renders view action buttons for each user', async () => { mockApiResponses(); renderTable(); diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index 22685344..754902f3 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -9,7 +9,6 @@ import { import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions'; import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; -import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter'; import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilter'; @@ -24,10 +23,9 @@ import messages from './messages'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; import AssignedRolesCell from './components/AssignedRolesCell'; import { EmailCell, NameCell } from './components/TeamMemberCells'; -import CollapseRowsOnChange from './components/CollapseRowsOnChange'; import MoreRolesToggle from './components/MoreRolesToggle'; import UserAssignmentsSubTable from './components/UserAssignmentsSubTable'; -import { createTeamMemberViewActionCell } from './components/TeamMemberViewActionCell'; +import TeamMemberViewActionCell from './components/TeamMemberViewActionCell'; interface TeamMembersTableProps { presetScope?: string; @@ -70,7 +68,6 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings); const { isCourseViewAllowed } = useViewTeamPermissions(); - const { isCourseEnabled } = useCourseAuthoringFlag(); const effectiveQuerySettings = useMemo(() => { if (isCourseViewAllowed || querySettings.roles) return querySettings; @@ -84,8 +81,6 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { refetch, } = useTeamMembersAssignments(effectiveQuerySettings, MAX_INLINE_ASSIGNMENTS); - const viewActionCell = useMemo(() => createTeamMemberViewActionCell({ isCourseEnabled }), [isCourseEnabled]); - const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; /** @@ -141,7 +136,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { { id: 'action', Header: intl.formatMessage(messages['authz.team.members.table.column.actions.title']), - Cell: viewActionCell, + Cell: TeamMemberViewActionCell, }, ]} columns={ @@ -200,7 +195,6 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { ] } > - diff --git a/src/authz-module/team-members/components/CollapseRowsOnChange.tsx b/src/authz-module/team-members/components/CollapseRowsOnChange.tsx deleted file mode 100644 index 1fa89679..00000000 --- a/src/authz-module/team-members/components/CollapseRowsOnChange.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useContext, useEffect, useRef } from 'react'; -import { DataTableContext } from '@openedx/paragon'; - -interface DataTableInstance { - state: { - pageIndex: number; - filters: Array<{ id: string; value: unknown }>; - }; - toggleAllRowsExpanded?: (expanded: boolean) => void; -} - -/** - * Collapses every expanded row whenever the page changes or a filter is applied or - * removed, so a stale breakdown never stays open over rows it no longer belongs to. - * - * Renders nothing — it exists to read `DataTableContext`, so it must sit inside - * ``. - */ -const CollapseRowsOnChange = () => { - const { state, toggleAllRowsExpanded } = useContext(DataTableContext) as DataTableInstance; - const { pageIndex, filters } = state; - // Serialized so the effect compares filter contents rather than array identity, which - // react-table replaces on every render. - const serializedFilters = JSON.stringify(filters); - const isFirstRender = useRef(true); - - useEffect(() => { - // Nothing is expanded on mount; collapsing here would fight the initial state. - if (isFirstRender.current) { - isFirstRender.current = false; - return; - } - toggleAllRowsExpanded?.(false); - }, [pageIndex, serializedFilters, toggleAllRowsExpanded]); - - return null; -}; - -export default CollapseRowsOnChange; diff --git a/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx index 9c3ad1a9..741420df 100644 --- a/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx +++ b/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx @@ -4,8 +4,21 @@ import userEvent from '@testing-library/user-event'; import { renderWrapper } from '@src/setupTest'; import type { TeamMember } from '@src/types'; import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; +import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import TeamMemberViewActionCell from './TeamMemberViewActionCell'; +jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({ + useCourseAuthoringFlag: jest.fn(), +})); + +const mockCourseAuthoringFlag = (isCourseEnabled: (scope: string) => boolean) => { + (useCourseAuthoringFlag as jest.Mock).mockReturnValue({ + isCourseAuthoringEnabled: true, + isCourseEnabled, + isLoading: false, + }); +}; + const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -43,6 +56,7 @@ const cellPropsFor = (overrides: Partial = {}) => ({ describe('TeamMemberViewActionCell', () => { beforeEach(() => { + mockCourseAuthoringFlag(() => true); initializeMockApp({ authenticatedUser: { userId: 1, @@ -79,10 +93,10 @@ describe('TeamMemberViewActionCell', () => { }); it('stays enabled when at least one assignment is viewable', () => { + mockCourseAuthoringFlag(() => false); renderWrapper( false} />, ); expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); @@ -90,7 +104,8 @@ describe('TeamMemberViewActionCell', () => { it('disables the action with a tooltip when every assignment sits in a disabled course', async () => { const user = userEvent.setup(); - renderWrapper( false} />); + mockCourseAuthoringFlag(() => false); + renderWrapper(); const viewButton = screen.getByRole('button', { name: /view/i }); expect(viewButton).toBeDisabled(); @@ -101,10 +116,10 @@ describe('TeamMemberViewActionCell', () => { it('stays enabled when the returned assignments are only part of the user total', () => { // Every returned assignment sits in a disabled course, but seven more were not returned. + mockCourseAuthoringFlag(() => false); renderWrapper( false} />, ); expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); @@ -112,9 +127,8 @@ describe('TeamMemberViewActionCell', () => { it('never calls the flag check with a missing scope', () => { const isCourseEnabled = jest.fn(() => true); - renderWrapper( - , - ); + mockCourseAuthoringFlag(isCourseEnabled); + renderWrapper(); expect(isCourseEnabled).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); diff --git a/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx index d9a80b69..f764b1f4 100644 --- a/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx +++ b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx @@ -3,18 +3,18 @@ import { IconButton } from '@openedx/paragon'; import { Visibility } from '@openedx/paragon/icons'; import { useNavigate } from 'react-router-dom'; import { buildAuditUserPath } from '@src/authz-module/constants'; +import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { DisabledCourseActionButton } from '@src/authz-module/components/TableCells'; import componentMessages from '@src/authz-module/components/messages'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; interface TeamMemberViewActionCellProps { row: { original: TeamMember }; - isCourseEnabled?: (scope: string) => boolean; } -const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled?: (scope: string) => boolean) => { +const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled: (scope: string) => boolean) => { const isCourseScope = !assignment.role?.startsWith('lib'); - if (!isCourseScope || isCourseEnabled === undefined) { + if (!isCourseScope) { return true; } return isCourseEnabled(assignment.scope); @@ -30,9 +30,12 @@ const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled?: (scope: * total exceeds the returned slice may hold viewable roles it does not contain; those rows * stay enabled rather than being blocked on incomplete evidence. */ -const TeamMemberViewActionCell = ({ row, isCourseEnabled }: TeamMemberViewActionCellProps) => { +const TeamMemberViewActionCell = ({ row }: TeamMemberViewActionCellProps) => { const { formatMessage } = useIntl(); const navigate = useNavigate(); + // Read here rather than threaded down from the table: the flag is a react-query query, + // so every row shares one request no matter how many cells ask for it. + const { isCourseEnabled } = useCourseAuthoringFlag(); const { assignments = [], assignmentCount, username } = row.original; const hasViewableAssignment = assignments.length === 0 @@ -59,10 +62,4 @@ const TeamMemberViewActionCell = ({ row, isCourseEnabled }: TeamMemberViewAction ); }; -export const createTeamMemberViewActionCell = ( - extraProps: { isCourseEnabled: (scope: string) => boolean }, -) => function customTeamMemberViewActionCell(cellProps) { - return ; -}; - export default TeamMemberViewActionCell; From f76dcfa81cf15a86774dd88f9c635bdf20da1cf7 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 17:34:49 +1000 Subject: [PATCH 14/24] refactor: use button insted f hyperlink for table taggle --- src/authz-module/components/TableCells.tsx | 26 +++++----- .../components/ViewMoreLink.test.tsx | 49 +++++++++++-------- src/authz-module/components/ViewMoreLink.tsx | 24 ++++----- 3 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 2d177dd6..4b8b87a0 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -6,8 +6,8 @@ import { import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; import { useContext, useMemo, type ComponentProps } from 'react'; import { - ADMIN_ROLES, ALL_ORGS_KEY, CONTEXT_TYPES, DJANGO_MANAGED_ROLES, getAggregateScopeType, - getPlatformAggregateScopeKey, getScopeContextType, MAP_ROLE_KEY_TO_LABEL, + ADMIN_ROLES, ALL_ORGS_KEY, DJANGO_MANAGED_ROLES, getAggregateScopeType, + getScopeContextType, GLOBAL_SCOPE_KEY, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, @@ -90,7 +90,7 @@ const ScopeCell = ({ row }: CellProps) => { const { scopeText, iconSrc } = useMemo(() => { const { role, scope, org } = row.original; - if (DJANGO_MANAGED_ROLES.includes(role) || scope === getPlatformAggregateScopeKey(CONTEXT_TYPES.GLOBAL)) { + if (DJANGO_MANAGED_ROLES.includes(role) || scope === GLOBAL_SCOPE_KEY) { return { scopeText: formatMessage(messages['authz.user.table.scope.global.label']), iconSrc: RESOURCE_ICONS.GLOBAL, @@ -156,17 +156,15 @@ const ViewAllPermissionsCell = ({ row }: CellProps) => { }; return ( -
- -
+ ); }; diff --git a/src/authz-module/components/ViewMoreLink.test.tsx b/src/authz-module/components/ViewMoreLink.test.tsx index 948d791e..01869c5b 100644 --- a/src/authz-module/components/ViewMoreLink.test.tsx +++ b/src/authz-module/components/ViewMoreLink.test.tsx @@ -16,48 +16,43 @@ describe('ViewMoreLink', () => { }); describe('rendering', () => { - it('renders the link with the provided label', () => { + it('exposes the label as a button, not a link, since it triggers an action in place', () => { renderWrapper(); - expect(screen.getByText('View more details')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'View more details' })).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); it('renders without an icon when iconSrc is not provided', () => { renderWrapper(); - const link = screen.getByText('View more details'); - expect(link).toBeInTheDocument(); - // The icon should not be present when iconSrc is not provided - expect(link.querySelector('svg')).not.toBeInTheDocument(); + const button = screen.getByRole('button', { name: 'View more details' }); + expect(button.querySelector('svg')).not.toBeInTheDocument(); }); it('renders with an icon when iconSrc is provided', () => { renderWrapper(); - const link = screen.getByText('View more details'); - expect(link).toBeInTheDocument(); - // The icon should be present when iconSrc is provided - expect(link.querySelector('svg')).toBeInTheDocument(); + const button = screen.getByRole('button', { name: 'View more details' }); + expect(button.querySelector('svg')).toBeInTheDocument(); }); }); describe('user interactions', () => { - it('calls onClick handler when user clicks the link', async () => { + it('calls onClick handler when user clicks the button', async () => { const user = userEvent.setup(); renderWrapper(); - const link = screen.getByText('View more details'); - await user.click(link); + await user.click(screen.getByRole('button', { name: 'View more details' })); expect(mockOnClick).toHaveBeenCalledTimes(1); }); - it('calls onClick handler when user clicks the link with an icon', async () => { + it('calls onClick handler when user clicks the button with an icon', async () => { const user = userEvent.setup(); renderWrapper(); - const link = screen.getByText('View more details'); - await user.click(link); + await user.click(screen.getByRole('button', { name: 'View more details' })); expect(mockOnClick).toHaveBeenCalledTimes(1); }); @@ -66,12 +61,26 @@ describe('ViewMoreLink', () => { const user = userEvent.setup(); renderWrapper(); - const link = screen.getByText('View more details'); - await user.click(link); - await user.click(link); - await user.click(link); + 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(); + + 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); + }); }); }); diff --git a/src/authz-module/components/ViewMoreLink.tsx b/src/authz-module/components/ViewMoreLink.tsx index 61820cbb..27063597 100644 --- a/src/authz-module/components/ViewMoreLink.tsx +++ b/src/authz-module/components/ViewMoreLink.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Hyperlink, Icon } from '@openedx/paragon'; +import { Button } from '@openedx/paragon'; interface ViewMoreLinkProps { label: string; @@ -7,21 +7,17 @@ interface ViewMoreLinkProps { iconSrc?: React.ComponentType | undefined; } +/** + * Link-styled trigger for an in-place action: expanding a row, revealing more items. + * + * A real ` ); export default ViewMoreLink; From ba9129d1ebefc759963b07e46fda3f5cfd1dee35 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 17:49:41 +1000 Subject: [PATCH 15/24] fix: refactor some errors in variables and docstrings --- .../components/TableCells.test.tsx | 6 -- src/authz-module/components/TableCells.tsx | 4 +- src/authz-module/constants.ts | 7 +- src/authz-module/index.scss | 81 ++++++------------- .../components/TeamMemberViewActionCell.tsx | 5 +- src/authz-module/utils.tsx | 19 ++--- 6 files changed, 36 insertions(+), 86 deletions(-) diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index 7191c6fd..a0db3059 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -192,12 +192,6 @@ describe('TableCells Components', () => { column: { id: 'scope' }, }); - it('displays "Global" for the platform-wide superadmin scope', () => { - renderWrapper(); - - expect(screen.getByText('Global')).toBeInTheDocument(); - }); - it('names a platform-wide course scope instead of showing its wildcard key', () => { renderWrapper(); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 4b8b87a0..8dec4dc8 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -7,7 +7,7 @@ import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; import { useContext, useMemo, type ComponentProps } from 'react'; import { ADMIN_ROLES, ALL_ORGS_KEY, DJANGO_MANAGED_ROLES, getAggregateScopeType, - getScopeContextType, GLOBAL_SCOPE_KEY, MAP_ROLE_KEY_TO_LABEL, + getScopeContextType, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, @@ -90,7 +90,7 @@ const ScopeCell = ({ row }: CellProps) => { const { scopeText, iconSrc } = useMemo(() => { const { role, scope, org } = row.original; - if (DJANGO_MANAGED_ROLES.includes(role) || scope === GLOBAL_SCOPE_KEY) { + if (DJANGO_MANAGED_ROLES.includes(role)) { return { scopeText: formatMessage(messages['authz.user.table.scope.global.label']), iconSrc: RESOURCE_ICONS.GLOBAL, diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 79ad6e86..d3a462a4 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -2,7 +2,6 @@ export const CONTEXT_TYPES = { LIBRARY: 'library', COURSE: 'course', - GLOBAL: 'global', } as const; export type ContextType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES]; @@ -21,7 +20,6 @@ export const getOrgAggregateScopeKey = (contextType: ContextType, orgSlug: strin const PLATFORM_AGGREGATE_SCOPE_KEYS = { [CONTEXT_TYPES.COURSE]: 'course-v1:*', [CONTEXT_TYPES.LIBRARY]: 'lib:*', - [CONTEXT_TYPES.GLOBAL]: '*', }; export const getPlatformAggregateScopeKey = (contextType: ContextType): string => { @@ -106,9 +104,8 @@ export const DJANGO_MANAGED_ROLES = ['django.superuser', 'django.globalstaff']; export const TABLE_DEFAULT_PAGE_SIZE = 10; /** - * Maximum assignments nested under each user in the team members table. Sent as the - * `assignments_limit` query param and used as the numerator of the sub-table's - * "Showing 03 of 10" footer; the denominator is the user's absolute `assignmentCount`. + * Cap on the assignments nested under each user in the team members table, sent as the + * `assignments_limit` query param. The sub-table footer counts what actually came back. */ export const MAX_INLINE_ASSIGNMENTS = 3; diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index f8c4b38a..50eff867 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -73,57 +73,38 @@ max-height: 80vh; } } - /* - Team members table truncation. The ellipsis itself comes from Paragon's `.text-truncate` - utility; these rules only supply what Paragon has no utility for — the `min-width: 0` - that lets a flex child shrink enough to clip, and the max-widths that place the cut near - the character counts in the design. - */ + + // Where `.text-truncate` cuts; sized to the design's character budget. .authz-cell-username { max-width: 15rem; } // ~24 characters .authz-cell-email { max-width: 17rem; } // ~28 characters - /* - Column sizing for the team members table. Auto-layout hands width to the short - Username/Email values and starves "Assigned roles", cutting the scope name off far - earlier than the design allows, so the columns are pinned explicitly. Widths follow - the design's character budget: ~24 for username, ~28 for email, the bulk to the role - summary, and just enough for the toggle and the action icon. - */ &.team-members-table { - /* - Roomier rows. Paragon's 8px is tight here because the assigned-roles cell stacks a - scope name over its organization, so every row is two lines tall. - */ + // Every row is two lines tall: the assigned-roles cell stacks scope over org. th, td { padding-top: var(--pgn-spacing-spacer-3); padding-bottom: var(--pgn-spacing-spacer-3); } - // The toggle reads as one phrase; wrapping it mid-label looks broken. + // Keep the "+N more roles" label on one line. td:nth-child(4) { white-space: nowrap; } /* - Column sizing, from `md` up only. Auto-layout hands width to the short Username and - Email values and starves "Assigned roles", cutting the scope name off far earlier - than the design allows, so the columns are pinned explicitly. Widths follow the - design's character budget: ~24 for username, ~28 for email, the bulk to the role - summary, and just enough for the toggle and the action icon. - - Below `md` these percentages would squeeze the toggle and action columns to a few - pixels, so the table falls back to content sizing and scrolls sideways inside - Paragon's `.pgn__data-table-container`, which is already `overflow-x: auto`. + Auto-layout starves "Assigned roles" in favour of the short Username/Email values, + so widths are pinned. Below `md` these percentages would crush the toggle and action + columns, so the table falls back to content sizing and scrolls inside Paragon's + `.pgn__data-table-container`. */ @media (--pgn-size-breakpoint-min-width-md) { .pgn__data-table { table-layout: fixed; } - th:nth-child(1) { width: 16%; } // Username, ~24 characters - th:nth-child(2) { width: 18%; } // Email, ~28 characters - th:nth-child(3) { width: 46%; } // Assigned roles: badge + scope + org subline + th:nth-child(1) { width: 16%; } // Username + th:nth-child(2) { width: 18%; } // Email + th:nth-child(3) { width: 46%; } // Assigned roles th:nth-child(4) { width: 13%; } // "+N more roles" toggle th:nth-child(5) { width: 7%; } // Actions @@ -131,7 +112,7 @@ overflow: hidden; } - // The breakdown sizes its own three columns; it must not inherit the widths above. + // The nested table sizes its own columns; it must not inherit the widths above. .team-members-table__subtable { .pgn__data-table { table-layout: auto; @@ -145,7 +126,7 @@ } } - // The breakdown is one line per row, so it keeps the tighter default rhythm. + // One line per row, so it keeps tighter padding. .team-members-table__subtable { th, td { @@ -156,12 +137,7 @@ } .team-members-table__subtable { - /* - The module turns `hr` into a horizontal rule below `sm`, for the page actions that - stack there. This one separates two labels in a footer that stays a row at every - width, so it keeps the vertical treatment — and is sized to the small text beside - it rather than to the page-header actions. - */ + // The module turns `hr` horizontal below `sm`; this footer divider stays vertical. hr { border-right: var(--pgn-size-border-width) solid var(--pgn-color-border); border-top: none; @@ -169,28 +145,22 @@ width: 0; } - // The breakdown sits inside a padded container of its own, so the module's blanket - // 1rem on every data-table wrapper would double it up. + // The Card already pads; the module's blanket 1rem would double it up. .pgn__data-table-wrapper { padding: 0; } - /* - Header band matching the resource-category rows of the roles & permissions matrix - (`bg-info-100 text-primary` in PermissionTable), so the two nested tables in the - module read as the same component. Paragon's DataTable renders its own `thead`, so - the utilities cannot be passed as a className and the tokens behind them are used. - */ + // Matches PermissionTable's `bg-info-100 text-primary` band. Paragon renders its own + // `thead`, so the tokens are used rather than the utility classes. thead th { background-color: var(--pgn-color-info-100); color: var(--pgn-color-primary-base); } /* - Paragon stripes rows with `.pgn__data-table.is-striped tr:nth-child(2n)` — a - descendant selector, so the striped outer table reaches into this nested one and - `isStriped={false}` on the inner table cannot override it. Reset it here, scoped to - a nested table that actually opted out, so an inner table that wants stripes keeps them. + Paragon stripes with `.pgn__data-table.is-striped tr:nth-child(2n)` — a descendant + selector, so the striped outer table reaches in and `isStriped={false}` on the inner + one cannot override it. The `:not()` keeps a nested table that wants stripes. */ .pgn__data-table:not(.is-striped) tr { background-color: transparent; @@ -202,20 +172,15 @@ } } - /* - Badge labels vary in length ("Course Admin" vs "Library Contributor"), so without a - floor the "In" connector and the scope start at a different x on every row. Sized to - the widest label so the three parts line up down the column. - */ + // Floor the badge to the widest label, so "in" and the scope align down the column. .authz-assigned-roles .authz-role-badge { min-width: 11rem; - // The row is muted so the bare "in" reads as a connector; the badge is content. - color: var(--pgn-color-gray-700); + color: var(--pgn-color-gray-700); // Undo the muted row color; the badge is content. margin-right: var(--pgn-spacing-spacer-3); } .authz-scope-cell { - min-width: 0; + min-width: 0; // Lets the flex child shrink far enough for `.text-truncate` to cut. max-width: 25rem; // org subline, ~40 characters &__name { diff --git a/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx index f764b1f4..56a2e5b3 100644 --- a/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx +++ b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx @@ -2,7 +2,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { IconButton } from '@openedx/paragon'; import { Visibility } from '@openedx/paragon/icons'; import { useNavigate } from 'react-router-dom'; -import { buildAuditUserPath } from '@src/authz-module/constants'; +import { buildAuditUserPath, CONTEXT_TYPES, getScopeContextType } from '@src/authz-module/constants'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { DisabledCourseActionButton } from '@src/authz-module/components/TableCells'; import componentMessages from '@src/authz-module/components/messages'; @@ -13,8 +13,7 @@ interface TeamMemberViewActionCellProps { } const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled: (scope: string) => boolean) => { - const isCourseScope = !assignment.role?.startsWith('lib'); - if (!isCourseScope) { + if (getScopeContextType(assignment.scope) === CONTEXT_TYPES.LIBRARY) { return true; } return isCourseEnabled(assignment.scope); diff --git a/src/authz-module/utils.tsx b/src/authz-module/utils.tsx index 47d5f2a6..caf951a8 100644 --- a/src/authz-module/utils.tsx +++ b/src/authz-module/utils.tsx @@ -34,23 +34,18 @@ export const getCellHeader = (columnId: string, columnTitle: string, filtersAppl }; /** - * Picks the resource icon for a scope from the scope key itself. Platform-wide scopes are - * resolved by the caller before this point, since they need the role to be recognised. + * Picks the course or library icon for a scope from the scope key itself. */ export const getScopeResourceIcon = (scope: string) => ( getScopeContextType(scope) === CONTEXT_TYPES.LIBRARY ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE ); -export const getScopeManageAction = (scope: string) => { - if (scope.startsWith('lib')) { - return CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; - } - if (scope.startsWith('course')) { - return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM; - } - // Default fallback or throw error for unknown scopes - return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM; -}; +/** Unrecognised scopes fall back to the course permission, as `getScopeContextType` does. */ +export const getScopeManageAction = (scope: string) => ( + getScopeContextType(scope) === CONTEXT_TYPES.LIBRARY + ? CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM + : CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM +); export const getScopeManageActionPermission = (scope: string) => { const action = getScopeManageAction(scope); From cc5d81c2bfea600a28339da4c840f50a2976c8cb Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 18:05:58 +1000 Subject: [PATCH 16/24] refactor: remove unnecessary styles --- src/authz-module/index.scss | 7 +------ .../team-members/components/AssignedRolesCell.tsx | 4 ++-- .../team-members/components/TeamMemberCells.tsx | 13 ++++++------- src/authz-module/team-members/messages.ts | 2 +- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 50eff867..2965c9b7 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -173,11 +173,7 @@ } // Floor the badge to the widest label, so "in" and the scope align down the column. - .authz-assigned-roles .authz-role-badge { - min-width: 11rem; - color: var(--pgn-color-gray-700); // Undo the muted row color; the badge is content. - margin-right: var(--pgn-spacing-spacer-3); - } + .authz-assigned-roles .authz-role-badge { min-width: 11rem; } .authz-scope-cell { min-width: 0; // Lets the flex child shrink far enough for `.text-truncate` to cut. @@ -185,7 +181,6 @@ &__name { max-width: 22rem; // ~35 characters - color: var(--pgn-color-gray-700); } &__org { diff --git a/src/authz-module/team-members/components/AssignedRolesCell.tsx b/src/authz-module/team-members/components/AssignedRolesCell.tsx index 3416cd4c..08b0286e 100644 --- a/src/authz-module/team-members/components/AssignedRolesCell.tsx +++ b/src/authz-module/team-members/components/AssignedRolesCell.tsx @@ -16,7 +16,7 @@ interface AssignedRolesCellProps { /** The role pill: a light rounded block with the person icon, not a Paragon Chip. */ export const RoleBadge = ({ role }: { role: string }) => ( -
+
{MAP_ROLE_KEY_TO_LABEL[role] || role}
@@ -62,7 +62,7 @@ const AssignedRolesCell = ({ row }: AssignedRolesCellProps) => {
- {scopeText} + {scopeText} diff --git a/src/authz-module/team-members/components/TeamMemberCells.tsx b/src/authz-module/team-members/components/TeamMemberCells.tsx index 51ba8d0d..908316aa 100644 --- a/src/authz-module/team-members/components/TeamMemberCells.tsx +++ b/src/authz-module/team-members/components/TeamMemberCells.tsx @@ -1,12 +1,9 @@ -import { useContext, type ReactNode } from 'react'; +import { useContext } from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import type { TeamMember } from '@src/types'; import messages from '../messages'; -/** Marks the row belonging to the signed-in user. */ -const Marker = (chunks: ReactNode[]) => {chunks}; - interface NameCellProps { row: { original: TeamMember }; } @@ -24,17 +21,19 @@ export const NameCell = ({ row }: NameCellProps) => { const { authenticatedUser } = useContext(AppContext); const { username = '' } = row.original; const isCurrentUser = username === authenticatedUser?.username; + // The cell is muted so the bare "(Me)" reads as a marker; the username is content. + const name = {username}; return ( - + {isCurrentUser ? ( ) - : username} + : name} ); }; diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts index e2dce965..f6eeceb4 100644 --- a/src/authz-module/team-members/messages.ts +++ b/src/authz-module/team-members/messages.ts @@ -8,7 +8,7 @@ const messages = defineMessages({ }, 'authz.team.members.table.username.current': { id: 'authz.team.members.table.username.current', - defaultMessage: '{username} (Me)', + defaultMessage: '{username} (Me)', description: 'Indicates the current user in the team members table', }, 'authz.team.members.table.column.email.title': { From d0d7c3cb6be5045f9995e70d1daadf21093e125b Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 18:14:27 +1000 Subject: [PATCH 17/24] test: clean up authz-home test --- src/authz-module/authz-home/index.test.tsx | 35 ++++++++++------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index 0ac7ab6f..a40db7b5 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -44,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 = {}) => { + (useViewTeamPermissions as jest.Mock).mockReturnValue({ ...permissionsInFlight, ...overrides }); +}; + const renderAuthzHome = () => renderWithAllProviders( @@ -54,11 +65,7 @@ describe('AuthzHome', () => { beforeEach(() => { // Call history is asserted on below, so it must not carry over between cases. jest.clearAllMocks(); - (useViewTeamPermissions as jest.Mock).mockReturnValue({ - isCourseViewAllowed: true, - isLibraryViewAllowed: true, - isLoading: false, - }); + 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); @@ -91,22 +98,14 @@ describe('AuthzHome', () => { }); it('denies access to the whole page when the user may view neither courses nor libraries', () => { - (useViewTeamPermissions as jest.Mock).mockReturnValue({ - isCourseViewAllowed: false, - isLibraryViewAllowed: false, - isLoading: false, - }); + 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', () => { - (useViewTeamPermissions as jest.Mock).mockReturnValue({ - isCourseViewAllowed: false, - isLibraryViewAllowed: false, - isLoading: true, - }); + mockViewTeamPermissions(); renderAuthzHome(); @@ -119,11 +118,7 @@ describe('AuthzHome', () => { }); it('renders the page when only library roles may be viewed', () => { - (useViewTeamPermissions as jest.Mock).mockReturnValue({ - isCourseViewAllowed: false, - isLibraryViewAllowed: true, - isLoading: false, - }); + mockViewTeamPermissions({ isLibraryViewAllowed: true, isLoading: false }); renderAuthzHome(); From 629761704dfbca533dfea9aa54224f2dba9d83d3 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 10 Sep 2026 18:29:18 +1000 Subject: [PATCH 18/24] refactor: extract row expassion to a shared hook --- src/authz-module/components/TableCells.tsx | 33 ++++------------- .../hooks/useExclusiveRowExpansion.ts | 30 ++++++++++++++++ .../components/MoreRolesToggle.tsx | 35 +++---------------- 3 files changed, 42 insertions(+), 56 deletions(-) create mode 100644 src/authz-module/hooks/useExclusiveRowExpansion.ts diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 8dec4dc8..e776995d 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -1,31 +1,25 @@ import { useIntl } from '@openedx/frontend-base'; import { - Delete, ExpandMore, + Delete, ExpandLess, ExpandMore, Info, } from '@openedx/paragon/icons'; import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; -import { useContext, useMemo, type ComponentProps } from 'react'; +import { useMemo, type ComponentProps } from 'react'; import { ADMIN_ROLES, ALL_ORGS_KEY, DJANGO_MANAGED_ROLES, getAggregateScopeType, getScopeContextType, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { - Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, + Icon, IconButton, OverlayTrigger, Tooltip, type DataTableCellProps, } from '@openedx/paragon'; +import { useExclusiveRowExpansion } from '@src/authz-module/hooks/useExclusiveRowExpansion'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import { RESOURCE_ICONS } from './constants'; import messages from './messages'; import ViewMoreLink from './ViewMoreLink'; -interface DataTableInstance { - state?: { - expanded?: Record; - }; - toggleRowExpanded?: (rowId: string, expanded: boolean) => void; -} - type CellProps = DataTableCellProps; type CellPropsWithValue = CellProps & { value: string; @@ -140,20 +134,7 @@ const PermissionsCell = ({ row }: CellProps) => { const ViewAllPermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); - const instance = useContext(DataTableContext) as DataTableInstance; - const handleToggleExpanded = () => { - if (!row.isExpanded && instance) { - // Close all other expanded rows first - const expanded = instance.state?.expanded || {}; - Object.keys(expanded).forEach(rowId => { - if (rowId !== row.id && expanded[rowId]) { - instance.toggleRowExpanded?.(rowId, false); - } - }); - } - // Toggle the current row - row.toggleRowExpanded?.(); - }; + const toggleExpanded = useExclusiveRowExpansion(row); return ( { ? messages['authz.user.table.view_all_permissions.link.text.close'] : messages['authz.user.table.view_all_permissions.link.text.open'], )} - onClick={handleToggleExpanded} - iconSrc={ExpandMore} + onClick={toggleExpanded} + iconSrc={row.isExpanded ? ExpandLess : ExpandMore} /> ); }; diff --git a/src/authz-module/hooks/useExclusiveRowExpansion.ts b/src/authz-module/hooks/useExclusiveRowExpansion.ts new file mode 100644 index 00000000..48e88c07 --- /dev/null +++ b/src/authz-module/hooks/useExclusiveRowExpansion.ts @@ -0,0 +1,30 @@ +import { useContext } from 'react'; +import { DataTableContext, type DataTableRow } from '@openedx/paragon'; + +interface DataTableInstance { + state?: { + expanded?: Record; + }; + toggleRowExpanded?: (rowId: string, expanded: boolean) => void; +} + +/** + * Returns a handler that toggles `row`, collapsing any other expanded row first so only one + * is open at a time. Paragon's `isExpandable` allows several, and a table whose rows expand + * into a nested table only reads clearly with one breakdown showing. + */ +export const useExclusiveRowExpansion = (row: DataTableRow) => { + const instance = useContext(DataTableContext) as DataTableInstance; + + return () => { + if (!row.isExpanded && instance) { + const expanded = instance.state?.expanded || {}; + Object.keys(expanded).forEach((rowId) => { + if (rowId !== row.id && expanded[rowId]) { + instance.toggleRowExpanded?.(rowId, false); + } + }); + } + row.toggleRowExpanded?.(); + }; +}; diff --git a/src/authz-module/team-members/components/MoreRolesToggle.tsx b/src/authz-module/team-members/components/MoreRolesToggle.tsx index 6a5e259a..26d02004 100644 --- a/src/authz-module/team-members/components/MoreRolesToggle.tsx +++ b/src/authz-module/team-members/components/MoreRolesToggle.tsx @@ -1,25 +1,13 @@ -import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { DataTableContext } from '@openedx/paragon'; +import type { DataTableRow } from '@openedx/paragon'; import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; +import { useExclusiveRowExpansion } from '@src/authz-module/hooks/useExclusiveRowExpansion'; import type { TeamMember } from '@src/types'; import messages from '../messages'; -interface DataTableInstance { - state?: { - expanded?: Record; - }; - toggleRowExpanded?: (rowId: string, expanded: boolean) => void; -} - interface MoreRolesToggleProps { - row: { - id: string; - isExpanded?: boolean; - original: TeamMember; - toggleRowExpanded?: () => void; - }; + row: DataTableRow; } /** @@ -31,32 +19,19 @@ interface MoreRolesToggleProps { */ const MoreRolesToggle = ({ row }: MoreRolesToggleProps) => { const { formatMessage } = useIntl(); - const instance = useContext(DataTableContext) as DataTableInstance; + const toggleExpanded = useExclusiveRowExpansion(row); const { assignmentCount } = row.original; if (!assignmentCount || assignmentCount <= 1) { return null; } - const handleToggleExpanded = () => { - if (!row.isExpanded && instance) { - // Close any other expanded row first, so only one breakdown is open at a time. - const expanded = instance.state?.expanded || {}; - Object.keys(expanded).forEach((rowId) => { - if (rowId !== row.id && expanded[rowId]) { - instance.toggleRowExpanded?.(rowId, false); - } - }); - } - row.toggleRowExpanded?.(); - }; - return ( ); From 05502eea01064d6459ff5f8759f1e17ff6369408 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Mon, 14 Sep 2026 15:18:37 +1000 Subject: [PATCH 19/24] refactor: fix aggregate labels --- .../components/TableCells.test.tsx | 38 +++++++++++---- src/authz-module/components/TableCells.tsx | 2 +- src/authz-module/components/messages.ts | 4 +- src/authz-module/messages.ts | 48 ++++++------------- .../hooks/useScopeListData.ts | 13 ++--- .../role-assignation-wizard/messages.ts | 20 ++++++++ .../components/AssignedRolesCell.test.tsx | 25 ++++++---- .../components/AssignedRolesCell.tsx | 34 +++++++++---- .../UserAssignmentsSubTable.test.tsx | 11 +++-- .../components/UserAssignmentsSubTable.tsx | 12 +++-- 10 files changed, 125 insertions(+), 82 deletions(-) diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index a0db3059..67381274 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -92,7 +92,7 @@ describe('TableCells Components', () => { }); describe('OrgCell', () => { - it('displays "All Organizations" for Django superuser role', () => { + it('displays "All platform" for Django superuser role', () => { const props = { value: 'Test Org', row: { @@ -106,11 +106,11 @@ describe('TableCells Components', () => { renderWrapper(); - expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.getByText('All platform')).toBeInTheDocument(); expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); }); - it('displays "All Organizations" for Django global staff role', () => { + it('displays "All platform" for Django global staff role', () => { const props = { value: 'Test Org', row: { @@ -124,10 +124,30 @@ describe('TableCells Components', () => { renderWrapper(); - expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.getByText('All platform')).toBeInTheDocument(); expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); }); + const orgProps = (original: { role: string; scope: string; org: string }) => ({ + value: original.org, + row: { id: '0', original: { permissionCount: 1, ...original } }, + column: { id: 'org' }, + }); + + it('displays "All platform" for a wildcard organization', () => { + renderWrapper(); + + expect(screen.getByText('All platform')).toBeInTheDocument(); + expect(screen.queryByText('*')).not.toBeInTheDocument(); + }); + + it('shows the organization for an organization-wide scope', () => { + renderWrapper(); + + expect(screen.getByText('MathDept')).toBeInTheDocument(); + expect(screen.queryByText('All platform')).not.toBeInTheDocument(); + }); + it('displays the actual org value for non-Django roles', () => { const props = { value: 'Test Organization', @@ -143,7 +163,7 @@ describe('TableCells Components', () => { renderWrapper(); expect(screen.getByText('Test Organization')).toBeInTheDocument(); - expect(screen.queryByText('All Organizations')).not.toBeInTheDocument(); + expect(screen.queryByText('All platform')).not.toBeInTheDocument(); }); }); @@ -195,26 +215,26 @@ describe('TableCells Components', () => { it('names a platform-wide course scope instead of showing its wildcard key', () => { renderWrapper(); - expect(screen.getByText('All courses on the platform')).toBeInTheDocument(); + expect(screen.getByText('All courses')).toBeInTheDocument(); expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); }); it('names a platform-wide library scope', () => { renderWrapper(); - expect(screen.getByText('All libraries on the platform')).toBeInTheDocument(); + expect(screen.getByText('All libraries')).toBeInTheDocument(); }); it('names an organization-wide course scope', () => { renderWrapper(); - expect(screen.getByText('All courses in this organization')).toBeInTheDocument(); + expect(screen.getByText('All courses')).toBeInTheDocument(); }); it('names an organization-wide library scope', () => { renderWrapper(); - expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + expect(screen.getByText('All libraries')).toBeInTheDocument(); }); it('shows the scope key when a wildcard belongs to a different organization', () => { diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index e776995d..4de9b54f 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -93,7 +93,7 @@ const ScopeCell = ({ row }: CellProps) => { const aggregateType = getAggregateScopeType(scope, org); return { scopeText: aggregateType - ? formatMessage(AGGREGATE_SCOPE_LABELS[aggregateType][getScopeContextType(scope)]) + ? formatMessage(AGGREGATE_SCOPE_LABELS[getScopeContextType(scope)]) : scope, iconSrc: getScopeResourceIcon(scope), }; diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index 2db86fd0..dba96cfa 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -58,8 +58,8 @@ const messages = defineMessages({ }, 'authz.user.table.org.all.organizations.label': { id: 'authz.user.table.org.all.organizations.label', - defaultMessage: 'All Organizations', - description: 'Label for the "All Organizations" message on the user assignments table when a user has a django managed role assigned.', + defaultMessage: 'All platform', + description: 'Shown in place of an organization for a role that is not limited to one organization: a wildcard org, or a Django-managed role.', }, 'authz.user.table.scope.global.label': { id: 'authz.user.table.scope.global.label', diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts index 4651c5aa..901d9f52 100644 --- a/src/authz-module/messages.ts +++ b/src/authz-module/messages.ts @@ -42,27 +42,18 @@ const messages = defineMessages( defaultMessage: 'The request took too long.

Please check your connection and try again.', description: 'Request timeout message', }, - // Aggregate scope labels. Shared: the wizard offers these scopes when assigning a - // role, and the team members table names them when displaying one already assigned. - 'authz.scope.aggregate.platform.course': { - id: 'authz.scope.aggregate.platform.course', - defaultMessage: 'All courses on the platform', - description: 'Label for the aggregate scope covering every course in the platform (course-v1:* scope).', + // Aggregate scope labels, shared by every table that lists a scope beside its + // organization. The scope column names only the kind of resource; the organization + // column says how far it reaches. The wizard keeps its own, longer wording. + 'authz.scope.aggregate.course': { + id: 'authz.scope.aggregate.course', + defaultMessage: 'All courses', + description: 'Scope column label for a role that covers every course, either across the platform or within one organization. The organization column shows which.', }, - 'authz.scope.aggregate.platform.library': { - id: 'authz.scope.aggregate.platform.library', - defaultMessage: 'All libraries on the platform', - description: 'Label for the aggregate scope covering every library in the platform (lib:* scope).', - }, - 'authz.scope.aggregate.org.course': { - id: 'authz.scope.aggregate.org.course', - defaultMessage: 'All courses in this organization', - description: 'Label for the aggregate scope covering every course of a single organization (course-v1:+* scope).', - }, - 'authz.scope.aggregate.org.library': { - id: 'authz.scope.aggregate.org.library', - defaultMessage: 'All libraries in this organization', - description: 'Label for the aggregate scope covering every library of a single organization (lib::* scope).', + 'authz.scope.aggregate.library': { + id: 'authz.scope.aggregate.library', + defaultMessage: 'All libraries', + description: 'Scope column label for a role that covers every library, either across the platform or within one organization. The organization column shows which.', }, 'authz.team.toast.retry.label': { id: 'authz.team.toast.retry.label', @@ -72,21 +63,10 @@ const messages = defineMessages( }, ); -/** - * Labels for the aggregate (wildcard) scopes, keyed by aggregate type and context type. - * - * Shared so the scope list in the assignment wizard and the scope column in the - * assignments tables name the same scope the same way. - */ +/** Aggregate scope labels keyed by the kind of resource the scope covers. */ export const AGGREGATE_SCOPE_LABELS = { - platform: { - course: messages['authz.scope.aggregate.platform.course'], - library: messages['authz.scope.aggregate.platform.library'], - }, - org: { - course: messages['authz.scope.aggregate.org.course'], - library: messages['authz.scope.aggregate.org.library'], - }, + course: messages['authz.scope.aggregate.course'], + library: messages['authz.scope.aggregate.library'], }; export default messages; diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index 88de3e9a..f5ea1083 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -3,9 +3,8 @@ import { useIntl } from '@openedx/frontend-base'; import { Scope } from '@src/types'; import { useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; -import { CONTEXT_TYPES, getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; +import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; import type { ContextType } from '@src/authz-module/constants'; -import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import messages from '../messages'; import useScopePermissions from './useScopePermissions'; @@ -84,11 +83,13 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) ? intl.formatMessage(messages['wizard.step2.scope.aggregate.description.course']) : intl.formatMessage(messages['wizard.step2.scope.aggregate.description.library']); - const labelContextType = contextType === 'course' ? CONTEXT_TYPES.COURSE : CONTEXT_TYPES.LIBRARY; + const platformAggregateLabel = contextType === 'course' + ? intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.course']) + : intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.library']); - const platformAggregateLabel = intl.formatMessage(AGGREGATE_SCOPE_LABELS.platform[labelContextType]); - - const orgAggregateLabel = intl.formatMessage(AGGREGATE_SCOPE_LABELS.org[labelContextType]); + const orgAggregateLabel = contextType === 'course' + ? intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.course']) + : intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.library']); const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission) ? { diff --git a/src/authz-module/role-assignation-wizard/messages.ts b/src/authz-module/role-assignation-wizard/messages.ts index 76aa334c..14318297 100644 --- a/src/authz-module/role-assignation-wizard/messages.ts +++ b/src/authz-module/role-assignation-wizard/messages.ts @@ -203,6 +203,16 @@ const messages = defineMessages({ defaultMessage: 'Includes current and future libraries', description: 'Description for the platform-wide aggregate scope item when context type is library', }, + 'wizard.step2.scope.aggregate.platform.label.course': { + id: 'wizard.step2.scope.aggregate.platform.label.course', + defaultMessage: 'All courses on the platform', + description: 'Display name for the platform-wide aggregate scope item when context type is course', + }, + 'wizard.step2.scope.aggregate.platform.label.library': { + id: 'wizard.step2.scope.aggregate.platform.label.library', + defaultMessage: 'All libraries on the platform', + description: 'Display name for the platform-wide aggregate scope item when context type is library', + }, // ScopeList — org section header 'wizard.step2.scopeList.orgLabel': { @@ -212,6 +222,16 @@ const messages = defineMessages({ }, // ScopeList — org-level aggregate scope items + 'wizard.step2.scopeList.aggregate.label.course': { + id: 'wizard.step2.scopeList.aggregate.label.course', + defaultMessage: 'All courses in this organization', + description: 'Display name for the org-wide aggregate scope item when context type is course', + }, + 'wizard.step2.scopeList.aggregate.label.library': { + id: 'wizard.step2.scopeList.aggregate.label.library', + defaultMessage: 'All libraries in this organization', + description: 'Display name for the org-wide aggregate scope item when context type is library', + }, // ScopeList — loading / empty states 'wizard.step2.scopeList.loading': { diff --git a/src/authz-module/team-members/components/AssignedRolesCell.test.tsx b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx index 20514982..51fea9f9 100644 --- a/src/authz-module/team-members/components/AssignedRolesCell.test.tsx +++ b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx @@ -53,43 +53,48 @@ describe('AssignedRolesCell', () => { expect(screen.getByText('course-v1:OpenedX+DemoX+DemoCourse')).toBeInTheDocument(); }); - it('names a platform-wide course scope instead of showing its wildcard key', () => { + it('summarises a platform-wide course scope as the whole platform', () => { renderWrapper(); - expect(screen.getByText('All courses on the platform')).toBeInTheDocument(); - expect(screen.getByText('All Organizations')).toBeInTheDocument(); + // Named once, on the scope line: the organization line is dropped rather than repeating it. + expect(screen.getAllByText('All platform')).toHaveLength(1); + // The resource kind belongs to the breakdown. + expect(screen.queryByText('All courses')).not.toBeInTheDocument(); expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); }); - it('names a platform-wide library scope', () => { + it('summarises a platform-wide library scope the same way', () => { renderWrapper(); - expect(screen.getByText('All libraries on the platform')).toBeInTheDocument(); + expect(screen.getByText('All platform')).toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); - it('names an organization-wide course scope, keeping the organization on its own line', () => { + it('summarises an organization-wide scope as just the organization', () => { renderWrapper(); - expect(screen.getByText('All courses in this organization')).toBeInTheDocument(); - expect(screen.getByText('MathDept')).toBeInTheDocument(); + // Named once, on the scope line, rather than repeated on an organization line below. + expect(screen.getAllByText('MathDept')).toHaveLength(1); + expect(screen.queryByText('All courses in this organization')).not.toBeInTheDocument(); }); - it('names an organization-wide library scope', () => { + it('summarises an organization-wide library scope the same way', () => { renderWrapper(); - expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + expect(screen.getAllByText('MathDept')).toHaveLength(1); + expect(screen.queryByText('All libraries in this organization')).not.toBeInTheDocument(); }); it('shows the raw role key when the API sends a role the UI has no label for', () => { diff --git a/src/authz-module/team-members/components/AssignedRolesCell.tsx b/src/authz-module/team-members/components/AssignedRolesCell.tsx index 08b0286e..69fe970d 100644 --- a/src/authz-module/team-members/components/AssignedRolesCell.tsx +++ b/src/authz-module/team-members/components/AssignedRolesCell.tsx @@ -2,12 +2,12 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { Business, Person } from '@openedx/paragon/icons'; import { - ALL_ORGS_KEY, getAggregateScopeType, getScopeContextType, MAP_ROLE_KEY_TO_LABEL, + ALL_ORGS_KEY, getAggregateScopeType, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; +import { RESOURCE_ICONS } from '@src/authz-module/components/constants'; import type { TeamMember } from '@src/types'; -import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import messages from '../messages'; interface AssignedRolesCellProps { @@ -43,10 +43,22 @@ const AssignedRolesCell = ({ row }: AssignedRolesCellProps) => { } = assignment; // An aggregate scope covers every course/library across the platform or within one org, // so it names no single resource and the API sends an empty display name for it. + /* + * A wildcard scope names no single resource, so the row summarises it by reach alone: + * the organization it covers, or the whole platform. The kind of resource ("All + * courses in this organization") is left to the breakdown, and the organization line + * is dropped since the scope line already says it. + */ const aggregateType = getAggregateScopeType(scope, org); - const scopeText = aggregateType - ? formatMessage(AGGREGATE_SCOPE_LABELS[aggregateType][getScopeContextType(scope)]) - : scopeDisplayName || scope; + let scopeIcon = getScopeResourceIcon(scope); + let scopeText = scopeDisplayName || scope; + if (aggregateType === 'platform') { + scopeIcon = RESOURCE_ICONS.GLOBAL; + scopeText = formatMessage(componentMessages['authz.user.table.org.all.organizations.label']); + } else if (aggregateType === 'org') { + scopeIcon = Business; + scopeText = org; + } const orgText = org === ALL_ORGS_KEY ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) @@ -61,13 +73,15 @@ const AssignedRolesCell = ({ row }: AssignedRolesCellProps) => { scope: (
- + {scopeText} - - - {orgText} - + {!aggregateType && ( + + + {orgText} + + )}
), }} diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx index 9751c180..ebf7c3e0 100644 --- a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx @@ -71,11 +71,12 @@ describe('UserAssignmentsSubTable', () => { ])} />); - expect(screen.getByText('All courses on the platform')).toBeInTheDocument(); - expect(screen.getByText('All libraries on the platform')).toBeInTheDocument(); - expect(screen.getByText('All courses in this organization')).toBeInTheDocument(); - expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); - expect(screen.getAllByText('All Organizations')).toHaveLength(2); + // The scope column names only the kind of resource... + expect(screen.getAllByText('All courses')).toHaveLength(2); + expect(screen.getAllByText('All libraries')).toHaveLength(2); + // ...and the organization column says how far it reaches. + expect(screen.getAllByText('All platform')).toHaveLength(2); + expect(screen.getAllByText('MathDept')).toHaveLength(2); expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); }); diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx index 64f39e68..c7455861 100644 --- a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx @@ -10,6 +10,7 @@ import { } from '@src/authz-module/constants'; import { getScopeResourceIcon } from '@src/authz-module/utils'; import componentMessages from '@src/authz-module/components/messages'; +import { RESOURCE_ICONS } from '@src/authz-module/components/constants'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import messages from '../messages'; @@ -31,7 +32,7 @@ const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { const { scope, scopeDisplayName, org } = assignmentRow.original; const aggregateType = getAggregateScopeType(scope, org); const scopeText = aggregateType - ? formatMessage(AGGREGATE_SCOPE_LABELS[aggregateType][getScopeContextType(scope)]) + ? formatMessage(AGGREGATE_SCOPE_LABELS[getScopeContextType(scope)]) : scopeDisplayName || scope; return ( @@ -42,18 +43,19 @@ const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { ); }; -// Mirrors ScopeNameCell's icon treatment so all three columns read alike. A platform-wide -// aggregate carries no single org, so it shows the all-organizations label instead. +// Mirrors ScopeNameCell's icon treatment so all three columns read alike. A wildcard org +// reaches past any one organization, so it gets the same globe and label as the row above. const OrgIconCell = ({ row: assignmentRow }: AssignmentCellProps) => { const { formatMessage } = useIntl(); const { org } = assignmentRow.original; - const orgText = org === ALL_ORGS_KEY + const isAllOrgs = org === ALL_ORGS_KEY; + const orgText = isAllOrgs ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) : org; return ( - + {orgText} ); From 5b54ea911acf7860187482aee5bd500d6da2f98d Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 18 Sep 2026 12:07:56 +1000 Subject: [PATCH 20/24] refactor: update doctrings and remove default assigment limit --- src/authz-module/constants.ts | 6 ----- src/authz-module/data/api.ts | 13 +++++----- src/authz-module/data/hooks.test.tsx | 4 +-- src/authz-module/data/hooks.ts | 25 ++++++++----------- .../team-members/TeamMembersTable.test.tsx | 16 ++---------- .../team-members/TeamMembersTable.tsx | 4 +-- .../components/UserAssignmentsSubTable.tsx | 6 ++--- src/types.ts | 5 ++-- 8 files changed, 30 insertions(+), 49 deletions(-) diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index d3a462a4..14f05ad6 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -103,11 +103,5 @@ export const DJANGO_MANAGED_ROLES = ['django.superuser', 'django.globalstaff']; export const TABLE_DEFAULT_PAGE_SIZE = 10; -/** - * Cap on the assignments nested under each user in the team members table, sent as the - * `assignments_limit` query param. The sub-table footer counts what actually came back. - */ -export const MAX_INLINE_ASSIGNMENTS = 3; - export const DEFAULT_FILTER_PAGE_SIZE = 5; export const ADMIN_ROLES = ['course_admin', 'library_admin']; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 22a37ace..30f346d4 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -141,13 +141,14 @@ export const revokeUserRoles = async ( }; /** - * Fetches team members grouped by user: one entry per user carrying up to - * `assignmentsLimit` of their role assignments plus their absolute `assignmentCount`. + * Fetches team members grouped by user: one entry per user carrying the first few of + * their role assignments plus `assignmentCount`, the number behind that slice. * - * Filters decide which users come back; they do not trim each user's nested - * `assignments` array, and `assignmentCount` always reflects the user's full total. + * The role, org and scope filters apply to assignments before they are grouped, so they + * decide both which users come back and which of each user's assignments are listed and + * counted. */ -export const getTeamMembersAssignments = async (querySettings: QuerySettings, assignmentsLimit: number) +export const getTeamMembersAssignments = async (querySettings: QuerySettings) : Promise => { const url = new URL(getApiUrl('/api/authz/v1/users/')); @@ -167,7 +168,7 @@ export const getTeamMembersAssignments = async (querySettings: QuerySettings, as url.searchParams.set('sort_by', querySettings.sortBy); url.searchParams.set('order', querySettings.order); } - url.searchParams.set('assignments_limit', assignmentsLimit.toString()); + // The endpoint defaults `assignments_limit` to 3; set it only to change that default. url.searchParams.set('page_size', querySettings.pageSize.toString()); url.searchParams.set('page', (querySettings.pageIndex + 1).toString()); diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 2013c773..d1421555 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -543,7 +543,7 @@ describe('useTeamMembersAssignments', () => { it('fetches and returns team members with their nested assignments', async () => { const { result } = renderHook( - () => useTeamMembersAssignments(mockQuerySettings, 3), + () => useTeamMembersAssignments(mockQuerySettings), { wrapper: createWrapper() }, ); await waitFor(() => { @@ -564,7 +564,7 @@ describe('useTeamMembersAssignments', () => { })), }); const { result } = renderHook( - () => useTeamMembersAssignments(mockQuerySettings, 3), + () => useTeamMembersAssignments(mockQuerySettings), { wrapper: createWrapper() }, ); await waitFor(() => { diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index db9e0d2e..044c3086 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -13,7 +13,7 @@ import { const authzQueryKeys = { all: [appId, 'authz'] as const, - teamMembersAssignments: (querySettings?: QuerySettings, assignmentsLimit?: number) => [...authzQueryKeys.all, 'teamMembersAssignments', querySettings, assignmentsLimit] as const, + teamMembersAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'teamMembersAssignments', querySettings] as const, orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const, scopes: (params?: Omit) => [...authzQueryKeys.all, 'scopes', params] as const, userRoles: (username?: string, querySettings?: QuerySettings) => [...authzQueryKeys.all, 'userRoles', username, querySettings] as const, @@ -85,26 +85,23 @@ export const useRevokeUserRoles = () => { /** * React Query hook to fetch team members grouped by user, with support for - * filtering, sorting, and pagination. Each result is one user carrying up to - * `assignmentsLimit` of their role assignments plus their absolute total. + * filtering, sorting, and pagination. Each result is one user carrying the first few of + * their role assignments plus how many there are; see `getTeamMembersAssignments` for + * how filters narrow both. * * @param querySettings - Parameters for filtering by roles, scopes, * organizations, search term, sorting, and pagination. - * @param assignmentsLimit - Maximum assignments to nest under each user. * * @example - * const { data: teamMembers } = useTeamMembersAssignments({ roles: 'editor', pageSize: 20 }, 3); + * const { data: teamMembers } = useTeamMembersAssignments({ roles: 'editor', pageSize: 20 }); */ -export const useTeamMembersAssignments = (querySettings: QuerySettings, assignmentsLimit: number) => { - const result = useQuery({ - queryKey: authzQueryKeys.teamMembersAssignments(querySettings, assignmentsLimit), - queryFn: () => getTeamMembersAssignments(querySettings, assignmentsLimit), +export const useTeamMembersAssignments = (querySettings: QuerySettings) => ( + useQuery({ + queryKey: authzQueryKeys.teamMembersAssignments(querySettings), + queryFn: () => getTeamMembersAssignments(querySettings), staleTime: 1000 * 60 * 30, // refetch after 30 minutes - retry: false, - refetchOnWindowFocus: false, - }); - return result; -}; + }) +); /** * React Query hook to fetch a paginated, searchable list of organizations. diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 25cad311..51aeeb37 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -6,7 +6,7 @@ import type { GetTeamMembersAssignmentsResponse } from '@src/authz-module/data/a import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions'; -import { AUTHZ_HOME_PATH, MAX_INLINE_ASSIGNMENTS } from '@src/authz-module/constants'; +import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; import TeamMembersTable from './TeamMembersTable'; @@ -202,17 +202,6 @@ describe('TeamMembersTable', () => { }); }); - it('requests the nested assignments capped at MAX_INLINE_ASSIGNMENTS', async () => { - mockApiResponses(); - renderTable(); - await waitFor(() => { - expect(useTeamMembersAssignments).toHaveBeenCalledWith( - expect.any(Object), - MAX_INLINE_ASSIGNMENTS, - ); - }); - }); - it('shows loading state initially', () => { mockApiResponses({ ...mockedTeamMembers, isLoading: true }); renderTable(); @@ -314,7 +303,7 @@ describe('TeamMembersTable', () => { expect(screen.getByText('Library User')).toBeInTheDocument(); }); - it('reports the absolute role total in the breakdown footer', async () => { + it('reports how many roles the listed rows were taken from in the breakdown footer', async () => { const user = userEvent.setup(); mockApiResponses(); renderTable(); @@ -485,7 +474,6 @@ describe('TeamMembersTable', () => { await waitFor(() => { expect(useTeamMembersAssignments).toHaveBeenCalledWith( expect.objectContaining({ roles: LIBRARY_ROLE_KEYS }), - MAX_INLINE_ASSIGNMENTS, ); }); }); diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index 754902f3..c68847c5 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -18,7 +18,7 @@ import { getCellHeader } from '@src/authz-module/utils'; import { useTeamMembersAssignments } from '@src/authz-module/data/hooks'; import type { GetTeamMembersAssignmentsResponse } from '@src/authz-module/data/api'; -import { MAX_INLINE_ASSIGNMENTS, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; +import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; import messages from './messages'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; import AssignedRolesCell from './components/AssignedRolesCell'; @@ -79,7 +79,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { isLoading: isLoadingTeamMembers, error, refetch, - } = useTeamMembersAssignments(effectiveQuerySettings, MAX_INLINE_ASSIGNMENTS); + } = useTeamMembersAssignments(effectiveQuerySettings); const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx index c7455861..99d82fad 100644 --- a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx @@ -65,9 +65,9 @@ const OrgIconCell = ({ row: assignmentRow }: AssignmentCellProps) => { * Role breakdown revealed when a team member row is expanded. * * Lists the assignments the API returned, in order, so the first entry matches the badge - * still shown in the collapsed row above. The footer total is the user's absolute role - * count, which is unaffected by any active filter and may exceed the rows listed here — - * `View all roles` leads to the audit page when it does. + * still shown in the collapsed row above. The footer total is `assignmentCount`, which + * may exceed the rows listed here — `View all roles` leads to the audit page when it + * does. */ const UserAssignmentsSubTable = ({ row }: UserAssignmentsSubTableProps) => { const { formatMessage } = useIntl(); diff --git a/src/types.ts b/src/types.ts index d44b9d22..808a6fa1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -127,8 +127,9 @@ export interface TeamMemberAssignment { /** * A team member as returned by the user-grouped assignments endpoint: one entry per - * user, carrying up to `assignments_limit` of their assignments. `assignmentCount` is - * the user's absolute total, so it can exceed `assignments.length`. + * user, carrying the first few of their assignments. `assignmentCount` counts every + * assignment behind that slice, so it can exceed `assignments.length`. Both are scoped + * to the active role, org and scope filters and to what the caller may view. */ export interface TeamMember { username: string; From 3349413530c1d9adc93cd31a64b7d52b73723cb2 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 22 Sep 2026 10:06:41 +1000 Subject: [PATCH 21/24] fix: update remaining imports after rebase and remove unused types --- src/authz-module/constants.ts | 8 +++- src/authz-module/data/api.ts | 3 +- .../team-members/TeamMembersTable.test.tsx | 2 +- .../components/AssignedRolesCell.test.tsx | 42 +++++++++++-------- .../components/AssignedRolesCell.tsx | 2 +- .../components/MoreRolesToggle.tsx | 2 +- .../components/TeamMemberCells.test.tsx | 11 +---- .../components/TeamMemberCells.tsx | 15 ++++--- .../TeamMemberViewActionCell.test.tsx | 11 +---- .../components/TeamMemberViewActionCell.tsx | 2 +- .../UserAssignmentsSubTable.test.tsx | 34 ++++++++------- .../components/UserAssignmentsSubTable.tsx | 6 ++- src/components/LoadingPage.tsx | 2 +- src/components/messages.ts | 2 +- src/types.ts | 20 --------- 15 files changed, 71 insertions(+), 91 deletions(-) diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 14f05ad6..731b8bce 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -47,8 +47,12 @@ export const getScopeContextType = (scope: string): ContextType => ( */ export const getAggregateScopeType = (scope: string, org?: string | null): 'platform' | 'org' | null => { const contextType = getScopeContextType(scope); - if (scope === getPlatformAggregateScopeKey(contextType)) { return 'platform'; } - if (org && scope === getOrgAggregateScopeKey(contextType, org)) { return 'org'; } + if (scope === getPlatformAggregateScopeKey(contextType)) { + return 'platform'; + } + if (org && scope === getOrgAggregateScopeKey(contextType, org)) { + return 'org'; + } return null; }; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 30f346d4..77652cbb 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -148,8 +148,7 @@ export const revokeUserRoles = async ( * decide both which users come back and which of each user's assignments are listed and * counted. */ -export const getTeamMembersAssignments = async (querySettings: QuerySettings) -: Promise => { +export const getTeamMembersAssignments = async (querySettings: QuerySettings): Promise => { const url = new URL(getApiUrl('/api/authz/v1/users/')); if (querySettings.roles) { diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 51aeeb37..8dc22d3b 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -213,7 +213,7 @@ describe('TeamMembersTable', () => { ...mockedTeamMembers, isLoading: false, error: new Error('Failed to fetch'), - // @ts-ignore - deliberately partial payload alongside the error + // @ts-expect-error - deliberately partial payload alongside the error data: { results: [] }, }); renderTable(); diff --git a/src/authz-module/team-members/components/AssignedRolesCell.test.tsx b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx index 51fea9f9..3971e3a6 100644 --- a/src/authz-module/team-members/components/AssignedRolesCell.test.tsx +++ b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx @@ -1,5 +1,5 @@ import { screen } from '@testing-library/react'; -import { renderWrapper } from '@src/setupTest'; +import { renderWrapper } from '@src/testUtils'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; import AssignedRolesCell from './AssignedRolesCell'; @@ -54,10 +54,12 @@ describe('AssignedRolesCell', () => { }); it('summarises a platform-wide course scope as the whole platform', () => { - renderWrapper(); + renderWrapper( + + ); // Named once, on the scope line: the organization line is dropped rather than repeating it. expect(screen.getAllByText('All platform')).toHaveLength(1); @@ -67,20 +69,24 @@ describe('AssignedRolesCell', () => { }); it('summarises a platform-wide library scope the same way', () => { - renderWrapper(); + renderWrapper( + + ); expect(screen.getByText('All platform')).toBeInTheDocument(); expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); it('summarises an organization-wide scope as just the organization', () => { - renderWrapper(); + renderWrapper( + + ); // Named once, on the scope line, rather than repeated on an organization line below. expect(screen.getAllByText('MathDept')).toHaveLength(1); @@ -88,10 +94,12 @@ describe('AssignedRolesCell', () => { }); it('summarises an organization-wide library scope the same way', () => { - renderWrapper(); + renderWrapper( + + ); expect(screen.getAllByText('MathDept')).toHaveLength(1); expect(screen.queryByText('All libraries in this organization')).not.toBeInTheDocument(); diff --git a/src/authz-module/team-members/components/AssignedRolesCell.tsx b/src/authz-module/team-members/components/AssignedRolesCell.tsx index 69fe970d..d8a4ebc1 100644 --- a/src/authz-module/team-members/components/AssignedRolesCell.tsx +++ b/src/authz-module/team-members/components/AssignedRolesCell.tsx @@ -1,4 +1,4 @@ -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@openedx/frontend-base'; import { Icon } from '@openedx/paragon'; import { Business, Person } from '@openedx/paragon/icons'; import { diff --git a/src/authz-module/team-members/components/MoreRolesToggle.tsx b/src/authz-module/team-members/components/MoreRolesToggle.tsx index 26d02004..bd0bcdb4 100644 --- a/src/authz-module/team-members/components/MoreRolesToggle.tsx +++ b/src/authz-module/team-members/components/MoreRolesToggle.tsx @@ -1,4 +1,4 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@openedx/frontend-base'; import type { DataTableRow } from '@openedx/paragon'; import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; diff --git a/src/authz-module/team-members/components/TeamMemberCells.test.tsx b/src/authz-module/team-members/components/TeamMemberCells.test.tsx index 470238b3..04617904 100644 --- a/src/authz-module/team-members/components/TeamMemberCells.test.tsx +++ b/src/authz-module/team-members/components/TeamMemberCells.test.tsx @@ -1,6 +1,5 @@ import { screen } from '@testing-library/react'; -import { initializeMockApp } from '@edx/frontend-platform/testing'; -import { renderWrapper } from '@src/setupTest'; +import { initializeMocks, renderWrapper } from '@src/testUtils'; import { EmailCell, NameCell } from './TeamMemberCells'; describe('NameCell', () => { @@ -18,13 +17,7 @@ describe('NameCell', () => { }, }; beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 1, - username: 'testuser', - email: 'testuser@example.com', - }, - }); + initializeMocks(); }); it('displays the username, never the full name', () => { diff --git a/src/authz-module/team-members/components/TeamMemberCells.tsx b/src/authz-module/team-members/components/TeamMemberCells.tsx index 908316aa..397884ef 100644 --- a/src/authz-module/team-members/components/TeamMemberCells.tsx +++ b/src/authz-module/team-members/components/TeamMemberCells.tsx @@ -1,6 +1,5 @@ import { useContext } from 'react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { AppContext } from '@edx/frontend-platform/react'; +import { FormattedMessage, SiteContext } from '@openedx/frontend-base'; import type { TeamMember } from '@src/types'; import messages from '../messages'; @@ -18,7 +17,7 @@ interface EmailCellProps { * signed-in user is marked so they can find themselves in the list. */ export const NameCell = ({ row }: NameCellProps) => { - const { authenticatedUser } = useContext(AppContext); + const { authenticatedUser } = useContext(SiteContext); const { username = '' } = row.original; const isCurrentUser = username === authenticatedUser?.username; // The cell is muted so the bare "(Me)" reads as a marker; the username is content. @@ -28,11 +27,11 @@ export const NameCell = ({ row }: NameCellProps) => { {isCurrentUser ? ( - - ) + + ) : name} ); diff --git a/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx index 741420df..412c16d6 100644 --- a/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx +++ b/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx @@ -1,7 +1,6 @@ import { screen } from '@testing-library/react'; -import { initializeMockApp } from '@edx/frontend-platform/testing'; import userEvent from '@testing-library/user-event'; -import { renderWrapper } from '@src/setupTest'; +import { initializeMocks, renderWrapper } from '@src/testUtils'; import type { TeamMember } from '@src/types'; import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; @@ -57,13 +56,7 @@ const cellPropsFor = (overrides: Partial = {}) => ({ describe('TeamMemberViewActionCell', () => { beforeEach(() => { mockCourseAuthoringFlag(() => true); - initializeMockApp({ - authenticatedUser: { - userId: 1, - username: 'testuser', - email: 'testuser@example.com', - }, - }); + initializeMocks(); mockNavigate.mockClear(); }); diff --git a/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx index 56a2e5b3..850c363f 100644 --- a/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx +++ b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx @@ -1,4 +1,4 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@openedx/frontend-base'; import { IconButton } from '@openedx/paragon'; import { Visibility } from '@openedx/paragon/icons'; import { useNavigate } from 'react-router-dom'; diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx index ebf7c3e0..2f771ebb 100644 --- a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx @@ -1,5 +1,5 @@ import { screen, within } from '@testing-library/react'; -import { renderWrapper } from '@src/setupTest'; +import { renderWrapper } from '@src/testUtils'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; import UserAssignmentsSubTable from './UserAssignmentsSubTable'; @@ -55,21 +55,23 @@ describe('UserAssignmentsSubTable', () => { }); it('names wildcard scopes instead of showing their keys', () => { - renderWrapper(); + renderWrapper( + + ); // The scope column names only the kind of resource... expect(screen.getAllByText('All courses')).toHaveLength(2); diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx index 99d82fad..96d1056a 100644 --- a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@openedx/frontend-base'; import { Card, DataTable, Icon, TableFooter, } from '@openedx/paragon'; @@ -20,7 +20,9 @@ interface UserAssignmentsSubTableProps { row: { original: TeamMember }; } -type AssignmentCellProps = { row: { original: TeamMemberAssignment } }; +interface AssignmentCellProps { + row: { original: TeamMemberAssignment }; +} // Same badge as the collapsed row above, so the breakdown reads consistently. const RoleBadgeCell = ({ row: assignmentRow }: AssignmentCellProps) => ( diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index bd9519c6..1e4b596f 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -1,4 +1,4 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@openedx/frontend-base'; import { Spinner, Container } from '@openedx/paragon'; import messages from './messages'; diff --git a/src/components/messages.ts b/src/components/messages.ts index 03e8c0bd..7f874f64 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -1,4 +1,4 @@ -import { defineMessages } from '@edx/frontend-platform/i18n'; +import { defineMessages } from '@openedx/frontend-base'; const messages = defineMessages({ 'loading.page.screenreader.text': { diff --git a/src/types.ts b/src/types.ts index 808a6fa1..598bae1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,26 +7,6 @@ export interface PermissionValidationResponse extends PermissionValidationReques allowed: boolean; } -// Libraries AuthZ types -export interface TeamMember { - username: string; - fullName: string; - email: string; - roles: string[]; - createdAt: string; - scope: { resource: string; type: 'COURSE' | 'LIBRARY' | 'GLOBAL' }; - organization: string; - role: string; -} - -export interface LibraryMetadata { - id: string; - org: string; - title: string; - slug: string; - allowPublicRead: boolean; -} - export interface RoleMetadata { role: string; name: string; From fae01bd2b74bbaf3179a658dc820e294232ebdc6 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 22 Sep 2026 10:16:27 +1000 Subject: [PATCH 22/24] test: fix subtable uri redirect validation --- .../team-members/components/UserAssignmentsSubTable.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx index 2f771ebb..4bc9ef4e 100644 --- a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx @@ -1,6 +1,7 @@ import { screen, within } from '@testing-library/react'; import { renderWrapper } from '@src/testUtils'; import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; import UserAssignmentsSubTable from './UserAssignmentsSubTable'; const courseAssignment: TeamMemberAssignment = { @@ -92,7 +93,7 @@ describe('UserAssignmentsSubTable', () => { renderWrapper(); expect(screen.getByRole('link', { name: /View all roles/ })) - .toHaveAttribute('href', '/authz/user/johndoe'); + .toHaveAttribute('href', `${AUTHZ_HOME_PATH}/user/johndoe`); }); it('omits the audit link when every role is already listed', () => { From f9f4d093c29f499fd8ec762731b96a002316a6d0 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 22 Sep 2026 11:32:06 +1000 Subject: [PATCH 23/24] refactor: use a more semantic name for the expandable button for tables --- ...ink.test.tsx => ExpandableButton.test.tsx} | 35 ++++++++----------- .../components/ExpandableButton.tsx | 32 +++++++++++++++++ .../components/TableCells.test.tsx | 10 ++++++ src/authz-module/components/TableCells.tsx | 8 ++--- src/authz-module/components/ViewMoreLink.tsx | 23 ------------ .../team-members/TeamMembersTable.test.tsx | 15 ++++++++ .../components/MoreRolesToggle.tsx | 7 ++-- 7 files changed, 78 insertions(+), 52 deletions(-) rename src/authz-module/components/{ViewMoreLink.test.tsx => ExpandableButton.test.tsx} (64%) create mode 100644 src/authz-module/components/ExpandableButton.tsx delete mode 100644 src/authz-module/components/ViewMoreLink.tsx diff --git a/src/authz-module/components/ViewMoreLink.test.tsx b/src/authz-module/components/ExpandableButton.test.tsx similarity index 64% rename from src/authz-module/components/ViewMoreLink.test.tsx rename to src/authz-module/components/ExpandableButton.test.tsx index 01869c5b..4092d175 100644 --- a/src/authz-module/components/ViewMoreLink.test.tsx +++ b/src/authz-module/components/ExpandableButton.test.tsx @@ -1,14 +1,14 @@ import { screen } from '@testing-library/react'; import { renderWrapper } from '@src/testUtils'; import userEvent from '@testing-library/user-event'; -import { ExpandMore } from '@openedx/paragon/icons'; -import ViewMoreLink from './ViewMoreLink'; +import ExpandableButton from './ExpandableButton'; -describe('ViewMoreLink', () => { +describe('ExpandableButton', () => { const mockOnClick = jest.fn(); const defaultProps = { label: 'View more details', onClick: mockOnClick, + isExpanded: false, }; beforeEach(() => { @@ -17,40 +17,33 @@ describe('ViewMoreLink', () => { describe('rendering', () => { it('exposes the label as a button, not a link, since it triggers an action in place', () => { - renderWrapper(); + renderWrapper(); expect(screen.getByRole('button', { name: 'View more details' })).toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); - it('renders without an icon when iconSrc is not provided', () => { - renderWrapper(); + it('shows a chevron and announces itself as closed while collapsed', () => { + renderWrapper(); const button = screen.getByRole('button', { name: 'View more details' }); - expect(button.querySelector('svg')).not.toBeInTheDocument(); + expect(button.querySelector('svg')).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-expanded', 'false'); }); - it('renders with an icon when iconSrc is provided', () => { - renderWrapper(); + it('announces itself as open once expanded', () => { + renderWrapper(); 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(); - - await user.click(screen.getByRole('button', { name: 'View more details' })); - - expect(mockOnClick).toHaveBeenCalledTimes(1); - }); - - it('calls onClick handler when user clicks the button with an icon', async () => { - const user = userEvent.setup(); - renderWrapper(); + renderWrapper(); await user.click(screen.getByRole('button', { name: 'View more details' })); @@ -59,7 +52,7 @@ describe('ViewMoreLink', () => { it('handles multiple clicks correctly', async () => { const user = userEvent.setup(); - renderWrapper(); + renderWrapper(); const button = screen.getByRole('button', { name: 'View more details' }); await user.click(button); @@ -71,7 +64,7 @@ describe('ViewMoreLink', () => { it('can be reached and activated with the keyboard alone', async () => { const user = userEvent.setup(); - renderWrapper(); + renderWrapper(); await user.tab(); expect(screen.getByRole('button', { name: 'View more details' })).toHaveFocus(); diff --git a/src/authz-module/components/ExpandableButton.tsx b/src/authz-module/components/ExpandableButton.tsx new file mode 100644 index 00000000..d02f5cec --- /dev/null +++ b/src/authz-module/components/ExpandableButton.tsx @@ -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 ` +); + +export default ExpandableButton; diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index 67381274..31814ea3 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -500,6 +500,16 @@ describe('TableCells Components', () => { expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); }); + it('reports whether the permissions breakdown is open', () => { + const { rerender } = renderWrapper(); + expect(screen.getByRole('button', { name: /view all permissions/i })) + .toHaveAttribute('aria-expanded', 'false'); + + rerender(); + expect(screen.getByRole('button', { name: /hide all permissions/i })) + .toHaveAttribute('aria-expanded', 'true'); + }); + it('handles toggle expand functionality with accordion behavior', async () => { const user = userEvent.setup(); const mockToggleRowExpanded = jest.fn(); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 4de9b54f..77d46f42 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -1,6 +1,6 @@ import { useIntl } from '@openedx/frontend-base'; import { - Delete, ExpandLess, ExpandMore, + Delete, Info, } from '@openedx/paragon/icons'; import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; @@ -18,7 +18,7 @@ import { getScopeResourceIcon } from '@src/authz-module/utils'; import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import { RESOURCE_ICONS } from './constants'; import messages from './messages'; -import ViewMoreLink from './ViewMoreLink'; +import ExpandableButton from './ExpandableButton'; type CellProps = DataTableCellProps; type CellPropsWithValue = CellProps & { @@ -137,14 +137,14 @@ const ViewAllPermissionsCell = ({ row }: CellProps) => { const toggleExpanded = useExclusiveRowExpansion(row); return ( - ); }; diff --git a/src/authz-module/components/ViewMoreLink.tsx b/src/authz-module/components/ViewMoreLink.tsx deleted file mode 100644 index 27063597..00000000 --- a/src/authz-module/components/ViewMoreLink.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { Button } from '@openedx/paragon'; - -interface ViewMoreLinkProps { - label: string; - onClick: () => void; - iconSrc?: React.ComponentType | undefined; -} - -/** - * Link-styled trigger for an in-place action: expanding a row, revealing more items. - * - * A real ` -); - -export default ViewMoreLink; diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 8dc22d3b..fc206152 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -303,6 +303,21 @@ describe('TeamMembersTable', () => { expect(screen.getByText('Library User')).toBeInTheDocument(); }); + it('reports whether a member\'s role breakdown is open', async () => { + const user = userEvent.setup(); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /\+9 more roles/ })).toHaveAttribute('aria-expanded', 'false'); + }); + + await user.click(screen.getByText('+9 more roles')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Hide roles/ })).toHaveAttribute('aria-expanded', 'true'); + }); + }); + it('reports how many roles the listed rows were taken from in the breakdown footer', async () => { const user = userEvent.setup(); mockApiResponses(); diff --git a/src/authz-module/team-members/components/MoreRolesToggle.tsx b/src/authz-module/team-members/components/MoreRolesToggle.tsx index bd0bcdb4..7033ca7c 100644 --- a/src/authz-module/team-members/components/MoreRolesToggle.tsx +++ b/src/authz-module/team-members/components/MoreRolesToggle.tsx @@ -1,7 +1,6 @@ import { useIntl } from '@openedx/frontend-base'; import type { DataTableRow } from '@openedx/paragon'; -import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; -import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; +import ExpandableButton from '@src/authz-module/components/ExpandableButton'; import { useExclusiveRowExpansion } from '@src/authz-module/hooks/useExclusiveRowExpansion'; import type { TeamMember } from '@src/types'; import messages from '../messages'; @@ -27,12 +26,12 @@ const MoreRolesToggle = ({ row }: MoreRolesToggleProps) => { } return ( - ); }; From 17870cbcd11de9bb5d2c08ad6fa87c2f558804ba Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Mon, 28 Sep 2026 20:05:26 +1000 Subject: [PATCH 24/24] style: fix linter after rebase --- .../team-members/TeamMembersTable.tsx | 180 +++++++++--------- 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index c68847c5..d907743b 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -105,100 +105,100 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { ); return ( - ( - - )} - additionalColumns={[ + ( + + )} + additionalColumns={[ + { + id: 'moreRoles', + Header: '', + Cell: MoreRolesToggle, + }, + { + id: 'action', + Header: intl.formatMessage(messages['authz.team.members.table.column.actions.title']), + Cell: TeamMemberViewActionCell, + }, + ]} + columns={ + [ { - id: 'moreRoles', - Header: '', - Cell: MoreRolesToggle, + id: 'username', + Header: intl.formatMessage(messages['authz.team.members.table.column.username.title']), + accessor: 'username', + Cell: NameCell, + filter: 'text', + Filter: TextFilter, + filterOrder: 1, }, { - id: 'action', - Header: intl.formatMessage(messages['authz.team.members.table.column.actions.title']), - Cell: TeamMemberViewActionCell, + Header: intl.formatMessage(messages['authz.team.members.table.column.email.title']), + accessor: 'email', + Cell: EmailCell, + disableFilters: true, + filter: 'text', + Filter: TextFilter, }, - ]} - columns={ - [ - { - id: 'username', - Header: intl.formatMessage(messages['authz.team.members.table.column.username.title']), - accessor: 'username', - Cell: NameCell, - filter: 'text', - Filter: TextFilter, - filterOrder: 1, - }, - { - Header: intl.formatMessage(messages['authz.team.members.table.column.email.title']), - accessor: 'email', - Cell: EmailCell, - disableFilters: true, - filter: 'text', - Filter: TextFilter, - }, - { - id: 'assignedRoles', - Header: intl.formatMessage(messages['authz.team.members.table.column.assigned.roles.title']), - Cell: AssignedRolesCell, - disableFilters: true, - disableSortBy: true, - }, - { - id: 'org', - Header: getCellHeader('org', intl.formatMessage(messages['authz.team.members.table.column.organization.title']), columnsWithFiltersApplied), - filter: 'includesValue', - defaultCanFilter: true, - Filter: OrgFilter, - filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.organization.title']), - filterOrder: 2, - }, - { - id: 'scope', - Header: getCellHeader('scope', intl.formatMessage(messages['authz.team.members.table.column.scope.title']), columnsWithFiltersApplied), - filter: 'includesValue', - defaultCanFilter: true, - Filter: ScopesFilter, - filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.scope.title']), - filterOrder: 4, - }, - { - id: 'role', - Header: getCellHeader('role', intl.formatMessage(messages['authz.team.members.table.column.role.title']), columnsWithFiltersApplied), - filter: 'includesValue', - defaultCanFilter: true, - Filter: RolesFilter, - filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.role.title']), - filterOrder: 3, - }, - ] - } - > - - - - + { + id: 'assignedRoles', + Header: intl.formatMessage(messages['authz.team.members.table.column.assigned.roles.title']), + Cell: AssignedRolesCell, + disableFilters: true, + disableSortBy: true, + }, + { + id: 'org', + Header: getCellHeader('org', intl.formatMessage(messages['authz.team.members.table.column.organization.title']), columnsWithFiltersApplied), + filter: 'includesValue', + defaultCanFilter: true, + Filter: OrgFilter, + filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.organization.title']), + filterOrder: 2, + }, + { + id: 'scope', + Header: getCellHeader('scope', intl.formatMessage(messages['authz.team.members.table.column.scope.title']), columnsWithFiltersApplied), + filter: 'includesValue', + defaultCanFilter: true, + Filter: ScopesFilter, + filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.scope.title']), + filterOrder: 4, + }, + { + id: 'role', + Header: getCellHeader('role', intl.formatMessage(messages['authz.team.members.table.column.role.title']), columnsWithFiltersApplied), + filter: 'includesValue', + defaultCanFilter: true, + Filter: RolesFilter, + filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.role.title']), + filterOrder: 3, + }, + ] + } + > + + + + ); };