From acb42de1be5488d4abab24259d4a1f5a85df6d8f Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 23 Mar 2026 09:01:14 -0600 Subject: [PATCH 01/15] feat: roles and permissions tab added --- src/authz-module/components/messages.ts | 5 + src/authz-module/courses/constants.ts | 401 ++++++++++++++++++ .../libraries-manager/messages.ts | 34 +- 3 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 src/authz-module/courses/constants.ts diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index 707dde7c..f447d750 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -21,6 +21,11 @@ const messages = defineMessages({ defaultMessage: 'Scroll to top', description: 'Alt text for the scroll to top anchor button', }, + 'authz.anchor.button.alt': { + id: 'authz.anchor.button.alt', + defaultMessage: 'Scroll to top', + description: 'Alt text for the scroll to top anchor button', + }, }); export default messages; diff --git a/src/authz-module/courses/constants.ts b/src/authz-module/courses/constants.ts new file mode 100644 index 00000000..54d803af --- /dev/null +++ b/src/authz-module/courses/constants.ts @@ -0,0 +1,401 @@ +import { PermissionMetadata, ResourceMetadata } from 'types'; + +export const CONTENT_COURSE_PERMISSIONS = { + VIEW_COURSE: 'courses.view_course', + CREATE_COURSE: 'courses.create_course', + EDIT_COURSE_CONTENT: 'courses.edit_course_content', + PUBLISH_COURSE_CONTENT: 'courses.publish_course_content', + + REVIEW_COURSE_LIBRARY_UPDATES: 'courses.manage_library_updates', + + VIEW_COURSE_UPDATES: 'courses.view_course_updates', + MANAGE_COURSE_UPDATES: 'courses.manage_course_updates', + + VIEW_COURSE_PAGES_RESOURCES: 'courses.view_pages_and_resources', + MANAGE_COURSE_PAGES_RESOURCES: 'courses.manage_pages_and_resources', + + VIEW_COURSE_FILES: 'courses.view_files', + CREATE_COURSE_FILES: 'courses.create_files', + EDIT_COURSE_FILES: 'courses.edit_files', + DELETE_COURSE_FILES: 'courses.delete_files', + + VIEW_COURSE_SCHEDULE: 'courses.view_schedule', + EDIT_COURSE_SCHEDULE: 'courses.edit_schedule', + VIEW_COURSE_DETAILS: 'courses.view_details', + EDIT_COURSE_DETAILS: 'courses.edit_details', + + VIEW_COURSE_GRADING_SETTINGS: 'courses.view_grading_settings', + EDIT_COURSE_GRADING_SETTINGS: 'courses.edit_grading_settings', + + VIEW_COURSE_TEAM: 'courses.view_course_team', + MANAGE_COURSE_TEAM: 'courses.manage_course_team', + MANAGE_COURSE_GROUP_CONFIGURATION: 'courses.manage_group_configurations', + + MANAGE_COURSE_TAGS: 'courses.manage_tags', + MANAGE_COURSE_TAXONOMIES: 'courses.manage_taxonomies', + + MANAGE_COURSE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', + MANAGE_COURSE_CERTIFICATES: 'courses.manage_certificates', + + IMPORT_COURSE: 'courses.import_course', + EXPORT_COURSE: 'courses.export_course', + EXPORT_COURSE_TAGS: 'courses.export_tags', + + VIEW_COURSE_CHECKLISTS: 'courses.view_checklists', + VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins', +}; + +export const courseResourceTypes: ResourceMetadata[] = [ + { key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.' }, + { key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.' }, + { key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.' }, + { key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.' }, + { key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.' }, + { key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.' }, + { key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.' }, + { key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.' }, + { key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies used to organize course content.' }, + { key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.' }, + { key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.' }, + { key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.' }, + +]; + +export const coursePermissions: PermissionMetadata[] = [ + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, + resource: 'course_access_content', + description: 'View course in the course list, access the course outline in read only mode, includes the "View Live" entry point.', + label: 'View course', + }, + { + key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE, + resource: 'course_access_content', + description: 'Create a new course in Studio.', + label: 'Create course', + }, + { + key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, + resource: 'course_access_content', + description: 'Edit course content, outline, units, components.', + label: 'Edit course content', + }, + { + key: CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, + resource: 'course_access_content', + description: 'Publish course content.', + label: 'Publish course content', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, + resource: 'course_library_updates', + description: 'Accept or reject library updates in Studio.', + label: 'Review library updates', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, + resource: 'course_updates_handouts', + description: 'View course updates and handouts.', + label: 'View course updates', + }, + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, + resource: 'course_updates_handouts', + description: 'Manage course updates and handouts, create, edit, delete.', + label: 'Manage course updates', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, + resource: 'course_pages_resources', + description: 'View Pages and Resources.', + label: 'View pages & resources', + }, + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, + resource: 'course_pages_resources', + description: 'Edit Pages and Resources, including toggles and content managed from that section.', + label: 'Manage pages & resources', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + resource: 'course_files', + description: 'View the Files page.', + label: 'View files', + }, + { + key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, + resource: 'course_files', + description: 'Upload files.', + label: 'Create files', + }, + { + key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, + resource: 'course_files', + description: 'Non destructive file actions, for example lock or unlock, exact actions depend on implementation.', + label: 'Edit files', + }, + { + key: CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES, + resource: 'course_files', + description: 'Delete files.', + label: 'Delete files', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, + resource: 'course_schedule_details', + description: 'View course schedule.', + label: 'View schedule', + }, + { + key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, + resource: 'course_schedule_details', + description: 'Edit course schedule.', + label: 'Edit schedule', + }, + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, + resource: 'course_schedule_details', + description: 'View course details.', + label: 'View course details', + }, + { + key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, + resource: 'course_schedule_details', + description: 'Edit course details, includes Course Summary, Course Pacing, Course Details, Course Pre requisite.', + label: 'Edit course details', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, + resource: 'course_grading', + description: 'View grading settings page.', + label: 'View grading settings', + }, + { + key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, + resource: 'course_grading', + description: 'Edit grading settings.', + label: 'Edit grading settings', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, + resource: 'course_team_group', + description: 'View the course team roster.', + label: 'View course team', + }, + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, + resource: 'course_team_group', + description: 'Edit course team membership and roles.', + label: 'Manage course team', + }, + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, + resource: 'course_team_group', + description: 'Manage content groups.', + label: 'Manage group configuration', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, + resource: 'course_tags_taxonomies', + description: 'Create, edit, delete tags.', + label: 'Manage tags', + }, + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES, + resource: 'course_tags_taxonomies', + description: 'Create, edit, delete taxonomies.', + label: 'Manage taxonomies', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, + resource: 'course_advanced_certificates', + description: 'Access and edit Advanced Settings.', + label: 'Manage advanced settings', + }, + { + key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, + resource: 'course_advanced_certificates', + description: 'Access and edit Certificates.', + label: 'Manage certificates', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE, + resource: 'course_import_export', + description: 'Show Import in Studio, this is treated as a high privilege action and effectively implies most authoring permissions.', + label: 'Import course', + }, + { + key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE, + resource: 'course_import_export', + description: 'Show Export in Studio.', + label: 'Export course', + }, + { + key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS, + resource: 'course_import_export', + description: 'Export tags.', + label: 'Export tags', + }, + + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, + resource: 'course_other', + description: 'View checklists.', + label: 'View checklists', + }, + { + key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, + resource: 'course_other', + description: 'Allow course or library admins to view the list of global Staff and Super Admin users.', + label: 'View global staff & super admins', + }, + +]; + +// roles hardcoded, todo: need to add the constants from above in order to merge the different permissions array. +export const rolesObject = [ + { + role: 'course_admin', + permissions: [ + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, + CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, + CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, + CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, + CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, + CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE, + CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE, + CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES, + ], + userCount: 1, + name: 'Course Admin', + description: 'course level administration, including access and role management for the course team, plus all Staff capabilities.', + }, + + { + role: 'course_staff', + permissions: [ + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, + CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, + CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, + CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, + CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, + CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE, + CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE, + CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS, + ], + userCount: 1, + name: 'Course Staff', + description: 'operating the course lifecycle in Studio, publishing content, handling scheduling, and managing high impact configuration for the course.', + }, + { + role: 'course_editor', + permissions: [ + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, + CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, + CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, + CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, + ], + userCount: 1, + name: 'Course Editor', + description: 'building and maintaining course content and supporting assets, without operational controls or high impact actions that can affect a live course.', + }, + { + role: 'course_auditor', + permissions: [ + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, + ], + userCount: 1, + name: 'Course Auditor', + description: ' QA, compliance review, content review, and general oversight, no changes in Studio.', + }, + +]; + +export const DEFAULT_TOAST_DELAY = 5000; +export const RETRY_TOAST_DELAY = 120_000; // 2 minutes +export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ + username: 'skeleton', + name: '', + email: '', + roles: [], +})); diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts index 9ed3f7d4..9d80312e 100644 --- a/src/authz-module/libraries-manager/messages.ts +++ b/src/authz-module/libraries-manager/messages.ts @@ -4,27 +4,47 @@ const messages = defineMessages({ 'library.authz.manage.page.title': { id: 'library.authz.manage.page.title', defaultMessage: 'Library Team Management', - description: 'Libreries AuthZ page title', + description: 'Libraries AuthZ page title', }, 'library.authz.breadcrumb.root': { id: 'library.authz.breadcrumb.root', defaultMessage: 'Manage Access', - description: 'Libreries AuthZ root breafcrumb', + description: 'Libraries AuthZ root breadcrumb', }, 'library.authz.tabs.team': { id: 'library.authz.tabs.team', defaultMessage: 'Team Members', - description: 'Libreries AuthZ title for the team management tab', + description: 'Libraries AuthZ title for the team management tab', }, 'library.authz.tabs.roles': { id: 'library.authz.tabs.roles', defaultMessage: 'Roles', - description: 'Libreries AuthZ title for the roles tab', + description: 'Libraries AuthZ title for the roles tab', }, 'library.authz.tabs.permissions': { id: 'library.authz.tabs.permissions', defaultMessage: 'Permissions', - description: 'Libreries AuthZ title for the permissions tab', + description: 'Libraries AuthZ title for the permissions tab', + }, + 'library.authz.tabs.permissionsRoles': { + id: 'library.authz.tabs.permissionsRoles', + defaultMessage: 'Roles and Permissions', + description: 'Libraries AuthZ title for the permissions and roles tab', + }, + 'library.authz.tabs.permissionsRoles.courses.alert.title': { + id: 'library.authz.tabs.permissionsRoles.courses.alert.title', + defaultMessage: 'Course Roles', + description: 'Libraries AuthZ title for the course roles alert', + }, + 'library.authz.tabs.permissionsRoles.courses.alert.description': { + id: 'library.authz.tabs.permissionsRoles.courses.alert.description', + defaultMessage: 'This list shows the permissions currently available in Authoring Studio. Some roles may grant additional permissions manages outside this interface.', + description: 'Libraries AuthZ description for the course roles alert', + }, + 'library.authz.tabs.permissionsRoles.courses.alert.link': { + id: 'library.authz.tabs.permissionsRoles.courses.alert.link', + defaultMessage: 'See full documentation', + description: 'Libraries AuthZ link for the course roles alert', }, 'library.authz.team.remove.user.toast.success.description': { id: 'library.authz.team.remove.user.toast.success.description', @@ -44,12 +64,12 @@ const messages = defineMessages({ 'library.authz.team.toast.502.error.message': { id: 'library.authz.team.toast.502.error.message', defaultMessage: 'We\'re having trouble connecting to our services.

Please try again later.', - description: 'Libraries bad getaway error message', + description: 'Libraries bad gateway error message', }, 'library.authz.team.toast.503.error.message': { id: 'library.authz.team.toast.503.error.message', defaultMessage: 'The service is temporarily unavailable.

Please try again in a few moments.', - description: 'Libraries service temporary unabailable message', + description: 'Libraries service temporary unavailable message', }, 'library.authz.team.toast.408.error.message': { id: 'library.authz.team.toast.408.error.message', From c109062979aefafd2eed5b3fc7e95392e6886b59 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 23 Mar 2026 12:05:22 -0600 Subject: [PATCH 02/15] feat: refactor to create new path for roles permissions tab --- src/authz-module/constants.ts | 2 + .../libraries-manager/constants.ts | 97 +++++++++++++++++-- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 3256bc2c..f3af5c0d 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,6 +1,8 @@ export const ROUTES = { LIBRARIES_TEAM_PATH: '/libraries/:libraryId', LIBRARIES_USER_PATH: '/libraries/:libraryId/:username', + ROLES_PERMISSIONS_PATH: '/', + }; export enum RoleOperationErrorStatus { diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts index d1368961..00a378e2 100644 --- a/src/authz-module/libraries-manager/constants.ts +++ b/src/authz-module/libraries-manager/constants.ts @@ -5,16 +5,20 @@ export const CONTENT_LIBRARY_PERMISSIONS = { MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags', VIEW_LIBRARY: 'content_libraries.view_library', + CREATE_LIBRARY_CONTENT: 'content_libraries.create_library_content', EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content', + DELETE_LIBRARY_CONTENT: 'content_libraries.delete_library_content', PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content', REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content', + IMPORT_LIBRARY_CONTENT: 'content_libraries.import_library_content', + + MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', + VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection', EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection', DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection', - MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', - VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', }; // Note: this information will eventually come from the backend API @@ -29,25 +33,104 @@ export const libraryRolesMetadata: RoleMetadata[] = [ export const libraryResourceTypes: ResourceMetadata[] = [ { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' }, { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' }, - { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' }, { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' }, + { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' }, ]; export const libraryPermissions: PermissionMetadata[] = [ - { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' }, { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', description: 'View content, search, filter, and sort within the library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, resource: 'library_content', description: 'Create content within the library.' }, { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', description: 'Edit content in draft mode' }, + { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, resource: 'library_content', description: 'Delete content within the library.' }, { key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', description: 'Publish content, making it available for reuse' }, { key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, resource: 'library_content', description: 'Import content from courses.' }, + + { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' }, { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' }, { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' }, +]; - { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, +export const rolesLibraryObject = [ + { + role: 'library_admin', + permissions: [ + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, + CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, + CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, + CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, + CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, + CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, + ], + user_count: 1, + name: 'Library Admin', + description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.', + }, + { + role: 'library_author', + permissions: [ + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, + CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, + CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, + CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT + ], + user_count: 1, + name: 'Library Author', + description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.', + }, + { + role: 'library_contributor', + permissions: [ + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, + CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, + CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, + CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, + CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT + + ], + user_count: 1, + name: 'Library Contributor', + description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.', + }, + { + role: 'library_user', + permissions: [ + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, + CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, + ], + user_count: 1, + name: 'Library User', + description: 'The Library User can view and reuse content but cannot edit or delete anything.', + }, ]; export const DEFAULT_TOAST_DELAY = 5000; From 896f6a77401df398697862f9e01985fba0633d22 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 23 Mar 2026 13:06:00 -0600 Subject: [PATCH 03/15] feat: icons added to roles --- src/authz-module/courses/constants.ts | 34 ++++++++++++------- .../libraries-manager/constants.ts | 16 +++++---- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/authz-module/courses/constants.ts b/src/authz-module/courses/constants.ts index 54d803af..b707b470 100644 --- a/src/authz-module/courses/constants.ts +++ b/src/authz-module/courses/constants.ts @@ -1,4 +1,14 @@ import { PermissionMetadata, ResourceMetadata } from 'types'; +import { + School, LibraryBooks, Article, Group, LocalOffer, + BookOpen, + Sync, + Folder, + Calendar, + Download, + DrawShapes, + CheckCircle, +} from '@openedx/paragon/icons'; export const CONTENT_COURSE_PERMISSIONS = { VIEW_COURSE: 'courses.view_course', @@ -46,18 +56,18 @@ export const CONTENT_COURSE_PERMISSIONS = { }; export const courseResourceTypes: ResourceMetadata[] = [ - { key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.' }, - { key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.' }, - { key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.' }, - { key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.' }, - { key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.' }, - { key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.' }, - { key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.' }, - { key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.' }, - { key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies used to organize course content.' }, - { key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.' }, - { key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.' }, - { key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.' }, + { key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.', icon: BookOpen }, + { key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.', icon: LibraryBooks }, + { key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.', icon: Sync }, + { key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Article }, + { key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Folder }, + { key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.', icon: Calendar }, + { key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.', icon: School }, + { key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.', icon: Group }, + { key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies used to organize course content.', icon: LocalOffer }, + { key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.', icon: CheckCircle }, + { key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.', icon: Download }, + { key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.', icon: DrawShapes }, ]; diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts index 00a378e2..f9f6dd0c 100644 --- a/src/authz-module/libraries-manager/constants.ts +++ b/src/authz-module/libraries-manager/constants.ts @@ -1,4 +1,7 @@ import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; +import { + Group, CollectionsBookmark, Notes, AutoAwesomeMosaic, +} from '@openedx/paragon/icons'; export const CONTENT_LIBRARY_PERMISSIONS = { DELETE_LIBRARY: 'content_libraries.delete_library', @@ -18,7 +21,6 @@ export const CONTENT_LIBRARY_PERMISSIONS = { CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection', EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection', DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection', - }; // Note: this information will eventually come from the backend API @@ -31,10 +33,10 @@ export const libraryRolesMetadata: RoleMetadata[] = [ ]; export const libraryResourceTypes: ResourceMetadata[] = [ - { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' }, - { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' }, - { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' }, - { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' }, + { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.', icon: CollectionsBookmark }, + { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.', icon: Notes }, + { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.', icon: Group }, + { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.', icon: AutoAwesomeMosaic }, ]; export const libraryPermissions: PermissionMetadata[] = [ @@ -94,7 +96,7 @@ export const rolesLibraryObject = [ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT + CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, ], user_count: 1, name: 'Library Author', @@ -113,7 +115,7 @@ export const rolesLibraryObject = [ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT + CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, ], user_count: 1, From 8198428a2007e1e80068c1c687ece04a0da48e9c Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 23 Mar 2026 14:46:23 -0600 Subject: [PATCH 04/15] chore: lint fix --- src/authz-module/courses/constants.ts | 48 ++++++++++++++----- .../libraries-manager/constants.ts | 24 ++++++---- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/src/authz-module/courses/constants.ts b/src/authz-module/courses/constants.ts index b707b470..c0d69e51 100644 --- a/src/authz-module/courses/constants.ts +++ b/src/authz-module/courses/constants.ts @@ -56,18 +56,42 @@ export const CONTENT_COURSE_PERMISSIONS = { }; export const courseResourceTypes: ResourceMetadata[] = [ - { key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.', icon: BookOpen }, - { key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.', icon: LibraryBooks }, - { key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.', icon: Sync }, - { key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Article }, - { key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Folder }, - { key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.', icon: Calendar }, - { key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.', icon: School }, - { key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.', icon: Group }, - { key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies used to organize course content.', icon: LocalOffer }, - { key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.', icon: CheckCircle }, - { key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.', icon: Download }, - { key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.', icon: DrawShapes }, + { + key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.', icon: BookOpen, + }, + { + key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.', icon: LibraryBooks, + }, + { + key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.', icon: Sync, + }, + { + key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Article, + }, + { + key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Folder, + }, + { + key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.', icon: Calendar, + }, + { + key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.', icon: School, + }, + { + key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.', icon: Group, + }, + { + key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies used to organize course content.', icon: LocalOffer, + }, + { + key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.', icon: CheckCircle, + }, + { + key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.', icon: Download, + }, + { + key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.', icon: DrawShapes, + }, ]; diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts index f9f6dd0c..73d67a98 100644 --- a/src/authz-module/libraries-manager/constants.ts +++ b/src/authz-module/libraries-manager/constants.ts @@ -33,10 +33,18 @@ export const libraryRolesMetadata: RoleMetadata[] = [ ]; export const libraryResourceTypes: ResourceMetadata[] = [ - { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.', icon: CollectionsBookmark }, - { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.', icon: Notes }, - { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.', icon: Group }, - { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.', icon: AutoAwesomeMosaic }, + { + key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.', icon: CollectionsBookmark, + }, + { + key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.', icon: Notes, + }, + { + key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.', icon: Group, + }, + { + key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.', icon: AutoAwesomeMosaic, + }, ]; export const libraryPermissions: PermissionMetadata[] = [ @@ -78,7 +86,7 @@ export const rolesLibraryObject = [ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, ], - user_count: 1, + userCount: 1, name: 'Library Admin', description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.', }, @@ -98,7 +106,7 @@ export const rolesLibraryObject = [ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, ], - user_count: 1, + userCount: 1, name: 'Library Author', description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.', }, @@ -118,7 +126,7 @@ export const rolesLibraryObject = [ CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, ], - user_count: 1, + userCount: 1, name: 'Library Contributor', description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.', }, @@ -129,7 +137,7 @@ export const rolesLibraryObject = [ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, ], - user_count: 1, + userCount: 1, name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete anything.', }, From 7e062a5898fa6969585a2bf60a73a20b40ffdf54 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Wed, 25 Mar 2026 15:25:23 -0600 Subject: [PATCH 05/15] chore: fix texts and removed unnecessary validations --- src/authz-module/courses/constants.ts | 4 ---- src/authz-module/libraries-manager/constants.ts | 4 ++-- src/authz-module/libraries-manager/messages.ts | 10 ++++++++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/authz-module/courses/constants.ts b/src/authz-module/courses/constants.ts index c0d69e51..352d3473 100644 --- a/src/authz-module/courses/constants.ts +++ b/src/authz-module/courses/constants.ts @@ -305,7 +305,6 @@ export const rolesObject = [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, @@ -345,7 +344,6 @@ export const rolesObject = [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, @@ -382,7 +380,6 @@ export const rolesObject = [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, @@ -411,7 +408,6 @@ export const rolesObject = [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts index 73d67a98..787edfb9 100644 --- a/src/authz-module/libraries-manager/constants.ts +++ b/src/authz-module/libraries-manager/constants.ts @@ -59,8 +59,8 @@ export const libraryPermissions: PermissionMetadata[] = [ { key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' }, { key: CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, resource: 'library_content', description: 'Import content from courses.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' }, { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' }, diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts index 9d80312e..6508bb67 100644 --- a/src/authz-module/libraries-manager/messages.ts +++ b/src/authz-module/libraries-manager/messages.ts @@ -36,6 +36,16 @@ const messages = defineMessages({ defaultMessage: 'Course Roles', description: 'Libraries AuthZ title for the course roles alert', }, + 'library.authz.tabs.permissionsRoles.courses.tab': { + id: 'library.authz.tabs.permissionsRoles.courses.tab', + defaultMessage: 'Courses', + description: 'Libraries AuthZ title for the course roles tab', + }, + 'library.authz.tabs.permissionsRoles.libraries.tab': { + id: 'library.authz.tabs.permissionsRoles.libraries.tab', + defaultMessage: 'Libraries', + description: 'Libraries AuthZ title for the libraries roles tab', + }, 'library.authz.tabs.permissionsRoles.courses.alert.description': { id: 'library.authz.tabs.permissionsRoles.courses.alert.description', defaultMessage: 'This list shows the permissions currently available in Authoring Studio. Some roles may grant additional permissions manages outside this interface.', From 8f38e608eb592a6ec6ae71762531b9f3a617fc77 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Wed, 25 Mar 2026 16:54:41 -0600 Subject: [PATCH 06/15] chore: aria labels updated --- src/authz-module/components/messages.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index f447d750..707dde7c 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -21,11 +21,6 @@ const messages = defineMessages({ defaultMessage: 'Scroll to top', description: 'Alt text for the scroll to top anchor button', }, - 'authz.anchor.button.alt': { - id: 'authz.anchor.button.alt', - defaultMessage: 'Scroll to top', - description: 'Alt text for the scroll to top anchor button', - }, }); export default messages; From 257681e6ee59945dbbfdf070eb42917672e7faa2 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Wed, 25 Mar 2026 17:06:06 -0600 Subject: [PATCH 07/15] chore: missing i18n added, tests updated --- src/authz-module/libraries-manager/messages.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts index 6508bb67..bbb2ad41 100644 --- a/src/authz-module/libraries-manager/messages.ts +++ b/src/authz-module/libraries-manager/messages.ts @@ -46,6 +46,21 @@ const messages = defineMessages({ defaultMessage: 'Libraries', description: 'Libraries AuthZ title for the libraries roles tab', }, + 'library.authz.tabs.permissionsRoles.libraries.tab.title': { + id: 'library.authz.tabs.permissionsRoles.libraries.tab.title', + defaultMessage: 'Library Roles', + description: 'Libraries AuthZ title for the library roles table', + }, + 'library.authz.tabs.permissionsRoles.courses.tab.title': { + id: 'library.authz.tabs.permissionsRoles.courses.tab.title', + defaultMessage: 'Course Roles', + description: 'Libraries AuthZ title for the course roles table', + }, + 'library.authz.tabs.permissionsRoles.courses.alert.note': { + id: 'library.authz.tabs.permissionsRoles.courses.alert.note', + defaultMessage: 'Note:', + description: 'Libraries AuthZ note for the course roles alert', + }, 'library.authz.tabs.permissionsRoles.courses.alert.description': { id: 'library.authz.tabs.permissionsRoles.courses.alert.description', defaultMessage: 'This list shows the permissions currently available in Authoring Studio. Some roles may grant additional permissions manages outside this interface.', From de0cf3e0ef252aa81c614106c2aca316b3965b9f Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Wed, 25 Mar 2026 17:07:31 -0600 Subject: [PATCH 08/15] chore: unnecessary path removed --- src/authz-module/constants.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index f3af5c0d..b6f411ca 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,7 +1,6 @@ export const ROUTES = { LIBRARIES_TEAM_PATH: '/libraries/:libraryId', LIBRARIES_USER_PATH: '/libraries/:libraryId/:username', - ROLES_PERMISSIONS_PATH: '/', }; From 59659e3c6794d0380b6b77590b35bb80d2a84a5d Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Thu, 26 Mar 2026 10:21:54 -0600 Subject: [PATCH 09/15] feat: disable columns and tooltip added --- src/authz-module/courses/constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/authz-module/courses/constants.ts b/src/authz-module/courses/constants.ts index 352d3473..e0500f6d 100644 --- a/src/authz-module/courses/constants.ts +++ b/src/authz-module/courses/constants.ts @@ -400,6 +400,7 @@ export const rolesObject = [ userCount: 1, name: 'Course Editor', description: 'building and maintaining course content and supporting assets, without operational controls or high impact actions that can affect a live course.', + disable: true, }, { role: 'course_auditor', @@ -417,6 +418,7 @@ export const rolesObject = [ userCount: 1, name: 'Course Auditor', description: ' QA, compliance review, content review, and general oversight, no changes in Studio.', + disable: true, }, ]; From 072542c42e868cd224c468279a3e6f15e3c95754 Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Mon, 30 Mar 2026 09:25:18 -0600 Subject: [PATCH 10/15] feat: creating the team members tab with the new ui --- src/authz-module/authz-home/index.tsx | 17 +- src/authz-module/components/AddRoleButton.tsx | 32 ++ src/authz-module/components/AuthZLayout.tsx | 7 +- src/authz-module/components/TableCells.tsx | 75 +++ .../TableControlBar/MultipleChoiceFilter.tsx | 127 +++++ .../components/TableControlBar/OrgFilter.tsx | 38 ++ .../TableControlBar/RolesFilter.tsx | 61 +++ .../TableControlBar/ScopesFilter.tsx | 40 ++ .../TableControlBar/SearchFilter.tsx | 30 ++ .../TableControlBar/TableControlBar.tsx | 195 ++++++++ .../components/TableControlBar/types.ts | 23 + .../components/TableFooter/TableFooter.tsx | 29 ++ src/authz-module/components/messages.ts | 46 ++ src/authz-module/components/utils.tsx | 14 + src/authz-module/constants.ts | 2 + src/authz-module/data/api.ts | 45 +- src/authz-module/data/hooks.ts | 30 +- .../hooks/useQuerySettings.test.tsx | 447 ++++++++++++++++++ src/authz-module/hooks/useQuerySettings.tsx | 96 ++++ src/authz-module/index.scss | 21 +- src/authz-module/messages.ts | 13 + .../team-members/TeamMembersTable.tsx | 237 ++++++++++ src/authz-module/team-members/messages.ts | 37 ++ src/types.ts | 23 + 24 files changed, 1661 insertions(+), 24 deletions(-) create mode 100644 src/authz-module/components/AddRoleButton.tsx create mode 100644 src/authz-module/components/TableCells.tsx create mode 100644 src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx create mode 100644 src/authz-module/components/TableControlBar/OrgFilter.tsx create mode 100644 src/authz-module/components/TableControlBar/RolesFilter.tsx create mode 100644 src/authz-module/components/TableControlBar/ScopesFilter.tsx create mode 100644 src/authz-module/components/TableControlBar/SearchFilter.tsx create mode 100644 src/authz-module/components/TableControlBar/TableControlBar.tsx create mode 100644 src/authz-module/components/TableControlBar/types.ts create mode 100644 src/authz-module/components/TableFooter/TableFooter.tsx create mode 100644 src/authz-module/components/utils.tsx create mode 100644 src/authz-module/hooks/useQuerySettings.test.tsx create mode 100644 src/authz-module/hooks/useQuerySettings.tsx create mode 100644 src/authz-module/messages.ts create mode 100644 src/authz-module/team-members/TeamMembersTable.tsx create mode 100644 src/authz-module/team-members/messages.ts diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx index f6cc7169..f7dea5b7 100644 --- a/src/authz-module/authz-home/index.tsx +++ b/src/authz-module/authz-home/index.tsx @@ -1,6 +1,8 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Tab, Tabs } from '@openedx/paragon'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import TeamMembersTable from 'authz-module/team-members/TeamMembersTable'; +import AddRoleButton from '@src/authz-module/components/AddRoleButton'; import RolesPermissions from '../roles-permissions/RolesPermissions'; import AuthZLayout from '../components/AuthZLayout'; @@ -9,6 +11,8 @@ import messages from './messages'; const AuthzHome = () => { const { hash } = useLocation(); const intl = useIntl(); + const [searchParams] = useSearchParams(); + const presetScope = searchParams.get('scope') || undefined; const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || ''; const pageTitle = intl.formatMessage(messages['authz.manage.page.title']); @@ -22,11 +26,7 @@ const AuthzHome = () => { pageTitle={pageTitle} pageSubtitle="" actions={ - [] - // this needs to be enable again once is refactored to be used outside of library context - // [ - // , - // ] + [] } > { defaultActiveKey={hash ? 'permissionsRoles' : 'team'} className="bg-light-100 px-5" > - - {/* TODO: once TeamTable is refactored we can call it here. For now, this tab will be empty. */} - {/* */} + + diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx new file mode 100644 index 00000000..8a44e750 --- /dev/null +++ b/src/authz-module/components/AddRoleButton.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@openedx/paragon'; +import { Plus } from '@openedx/paragon/icons'; + +import baseMessages from '@src/authz-module/messages'; +import { useNavigate } from 'react-router-dom'; + +interface AddRoleButtonProps { + presetUsername?: string; +} + +const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + + const handleClick = () => { + const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`; + navigate(assignRolePath); + }; + + return ( + + ); +}; + +export default AddRoleButton; diff --git a/src/authz-module/components/AuthZLayout.tsx b/src/authz-module/components/AuthZLayout.tsx index 9845b90c..06b572cc 100644 --- a/src/authz-module/components/AuthZLayout.tsx +++ b/src/authz-module/components/AuthZLayout.tsx @@ -14,9 +14,10 @@ interface AuthZLayoutProps extends AuthZTitleProps { const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => ( <> {children} diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx new file mode 100644 index 00000000..84d97211 --- /dev/null +++ b/src/authz-module/components/TableCells.tsx @@ -0,0 +1,75 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton } from '@openedx/paragon'; +import { AppContext } from '@edx/frontend-platform/react'; +import { + RemoveRedEye, Language, School, LibraryBooks, +} from '@openedx/paragon/icons'; +import { TableCellValue, TeamMember, AppContextType } from '@src/types'; +import { useNavigate } from 'react-router-dom'; +import { useContext } from 'react'; +import messages from './messages'; + +type CellProps = TableCellValue; +type ExtendedCellProps = CellProps & { + value: string; + cell: { + getCellProps: (props?: Record) => Record; + }; +}; + +const SCOPE_ICONS = { + COURSE: School, + LIBRARY: LibraryBooks, + GLOBAL: Language, +}; + +const NameCell = ({ row }: CellProps) => { + const intl = useIntl(); + const { authenticatedUser } = useContext(AppContext) as AppContextType; + const username = authenticatedUser?.username; + + if (row.original.username === username) { + return ( + + {row.original.fullName} + {intl.formatMessage(messages['authz.table.username.current'])} + + ); + } + return row.original.fullName; +}; + +const ActionCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + const navigate = useNavigate(); + const viewPath = `/authz/user/${row.original.username}`; + return ( + navigate(viewPath)} + /> + ); +}; + +const ScopeCell = ({ row }: CellProps) => { + const { scope } = row.original; + const iconSrc = SCOPE_ICONS[scope.type]; + return ( + + {iconSrc && } + {scope.resource} + + ); +}; + +const RoleCell = ({ value, cell }: ExtendedCellProps) => ( + + {value} + +); + +export { + NameCell, ActionCell, ScopeCell, RoleCell, +}; diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx new file mode 100644 index 00000000..59bf4642 --- /dev/null +++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx @@ -0,0 +1,127 @@ +import { + Dropdown, Form, Icon, Stack, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { FilterList, Search } from '@openedx/paragon/icons'; +import { useState } from 'react'; +import messages from '../messages'; +import { MultipleChoiceFilterProps } from './types'; + +const MultipleChoiceFilter = ({ + filterButtonText, + filterChoices, + filterValue, + setFilter, + isGrouped = false, + isSearchable = false, + onSearchChange, + iconSrc, + disabled = false, +}: MultipleChoiceFilterProps) => { + const [searchValue, setSearchValue] = useState(undefined); + const { formatMessage } = useIntl(); + const checkedBoxes = filterValue || []; + const handleClickCheckbox = (value) => { + const newValue = { + groupName: filterButtonText?.toLocaleLowerCase() || '', + value, + displayName: value, + }; + if (checkedBoxes.includes(value)) { + const newCheckedBoxes = checkedBoxes.filter((val) => val !== value); + return setFilter(newCheckedBoxes, newValue); + } + checkedBoxes.push(value); + return setFilter(checkedBoxes, newValue); + }; + + const getGroupedChoices = () => { + const groupedFilterChoices = filterChoices.reduce((groups, choice) => { + const groupName = choice.groupName || 'Ungrouped'; + const icon = choice.groupIcon || undefined; + if (!groups.has(groupName)) { + groups.set(groupName, { groupName, options: [], icon }); + } + groups.get(groupName)!.options.push({ + displayName: choice.displayName, + value: choice.value, + }); + return groups; + }, new Map; icon?: any }>()); + return Array.from(groupedFilterChoices.values()); + }; + return ( + + 0 ? 'primary' : 'outline-primary'}> + + {iconSrc && } + {filterButtonText} + {checkedBoxes.length > 0 && ` (${checkedBoxes.length})`} + + + + + + {isSearchable && ( + } + placeholder={formatMessage(messages['authz.table.controlbar.search'])} + onChange={(e) => { + setSearchValue(e.target.value); + onSearchChange?.(e.target.value); + }} + value={searchValue} + /> + )} + + {/** TODO: Change for actual values */} + {formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })} + {!isGrouped ? filterChoices.map(({ + displayName, value, + }) => ( + handleClickCheckbox(value)} + aria-label={displayName} + disabled={checkedBoxes.includes(value) ? false : disabled} + > + {displayName} + + )) + : getGroupedChoices().map(({ groupName, icon, options }) => ( +
+
+ {icon && } + {groupName} +
+ {options.map(({ displayName, value }) => ( + handleClickCheckbox(value)} + disabled={checkedBoxes.includes(value) ? false : disabled} + aria-label={displayName} + > + {displayName} + + ))} +
+ ))} +
+
+
+ ); +}; + +export default MultipleChoiceFilter; diff --git a/src/authz-module/components/TableControlBar/OrgFilter.tsx b/src/authz-module/components/TableControlBar/OrgFilter.tsx new file mode 100644 index 00000000..94822c2a --- /dev/null +++ b/src/authz-module/components/TableControlBar/OrgFilter.tsx @@ -0,0 +1,38 @@ +import React, { useMemo } from 'react'; +import { Business } from '@openedx/paragon/icons'; +import { useOrgs } from '@src/authz-module/data/hooks'; +import { MultipleChoiceFilterProps } from './types'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; + +type OrgFilterProps = Omit; + +const OrgFilter = ({ + filterButtonText, filterValue, setFilter, disabled, +}: OrgFilterProps) => { + const [searchValue, setSearchValue] = React.useState(undefined); + const { data: orgsData = { orgs: [] } } = useOrgs(searchValue); + + const filterChoices = useMemo(() => orgsData.orgs.map((org) => ({ + displayName: org.name, + value: org.id, + })), [orgsData]); + + const handleSearchChange = (value: string) => { + setSearchValue(value); + }; + + return ( + + ); +}; + +export default OrgFilter; diff --git a/src/authz-module/components/TableControlBar/RolesFilter.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx new file mode 100644 index 00000000..46628e9d --- /dev/null +++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import { + Person, Language, School, LibraryBooks, +} from '@openedx/paragon/icons'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; +import { MultipleChoiceFilterProps } from './types'; + +type RolesFilterProps = Omit; + +const RolesFilter = ({ + filterButtonText, filterValue, setFilter, disabled, +}: RolesFilterProps) => { + // TODO: use a constant + const filterChoices = useMemo(() => [ + { + groupName: 'Global', groupIcon: Language, displayName: 'Super Admin', value: 'Super Admin', + }, + { + groupName: 'Global', groupIcon: Language, displayName: 'Global Staff', value: 'Global Staff', + }, + + { + groupName: 'Course', groupIcon: School, displayName: 'Course Admin', value: 'Course Admin', + }, + { + groupName: 'Course', groupIcon: School, displayName: 'Course Staff', value: 'Course Staff', + }, + { + groupName: 'Course', groupIcon: School, displayName: 'Course Editor', value: 'Course Editor', + }, + { + groupName: 'Course', groupIcon: School, displayName: 'Course Auditor', value: 'Course Auditor', + }, + + { + groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Admin', value: 'Library Admin', + }, + { + groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Author', value: 'Library Author', + }, + { + groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Collaborator', value: 'Library Collaborator', + }, + { + groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library User', value: 'Library User', + }, + ], []); + return ( + + ); +}; + +export default RolesFilter; diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx new file mode 100644 index 00000000..755e53f4 --- /dev/null +++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import { LocationOn } from '@openedx/paragon/icons'; +import { useScopes } from '@src/authz-module/data/hooks'; +import { MultipleChoiceFilterProps } from './types'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; + +type ScopesFilterProps = Omit; + +const ScopesFilter = ({ + filterButtonText, filterValue, setFilter, disabled, +}: ScopesFilterProps) => { + const [searchValue, setSearchValue] = React.useState(undefined); + const { data: scopesData = { scopes: [] } } = useScopes(searchValue); + + const filterChoices = useMemo(() => scopesData.scopes.map((scope) => ({ + displayName: scope.name, + value: scope.key, + groupName: scope.organization.name, + })), [scopesData]); + + const handleSearchChange = (value: string) => { + setSearchValue(value); + }; + + return ( + + ); +}; + +export default ScopesFilter; diff --git a/src/authz-module/components/TableControlBar/SearchFilter.tsx b/src/authz-module/components/TableControlBar/SearchFilter.tsx new file mode 100644 index 00000000..b2480f1d --- /dev/null +++ b/src/authz-module/components/TableControlBar/SearchFilter.tsx @@ -0,0 +1,30 @@ +import { + Form, + Icon, +} from '@openedx/paragon'; +import { Search } from '@openedx/paragon/icons'; + +interface SearchFilterProps { + filterValue: string; + setFilter: (value: string) => void; + placeholder: string; +} + +const SearchFilter = ({ + filterValue, setFilter, placeholder, +}: SearchFilterProps) => ( + + } + value={filterValue || ''} + type="text" + onChange={e => { + setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely + }} + placeholder={placeholder} + /> + +); + +export default SearchFilter; diff --git a/src/authz-module/components/TableControlBar/TableControlBar.tsx b/src/authz-module/components/TableControlBar/TableControlBar.tsx new file mode 100644 index 00000000..4944208f --- /dev/null +++ b/src/authz-module/components/TableControlBar/TableControlBar.tsx @@ -0,0 +1,195 @@ +import { useContext, useEffect, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + DataTableContext, + Stack, + TextFilter, + Button, + Chip, + Alert, + Icon, +} from '@openedx/paragon'; +import { + Business, Close, LocationOn, Person, + Warning, +} from '@openedx/paragon/icons'; + +import { MAX_TABLE_FILTERS_APPLIED } from '@src/authz-module/constants'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; +import SearchFilter from './SearchFilter'; +import messages from '../messages'; +import RolesFilter from './RolesFilter'; +import OrgFilter from './OrgFilter'; +import ScopesFilter from './ScopesFilter'; +import { FilterApplied, FilterChoice } from './types'; + +const FILTER_CHIPS_ICONS = { + role: Person, + organization: Business, + scope: LocationOn, +}; + +interface TableControlBarProps { + onFilterChange?: (filters: string[]) => void; + initialFilters?: FilterApplied[]; +} + +const TableControlBar = ({ onFilterChange, initialFilters }: TableControlBarProps) => { + const intl = useIntl(); + // applied filters in the order they were selected by the user, to display on the control bar as chips + const [chronologicalFilters, setChronologicalFilters] = useState([]); + const [filtersLimitReached, setFiltersLimitReached] = useState(false); + const { + columns, + setAllFilters, + state, + // @ts-ignore-next-line - Paragon's DataTableContext is not typed + } = useContext(DataTableContext); + + useEffect(() => { + if (initialFilters) { + const formattedInitialFilters = initialFilters.map((filter) => ({ + groupName: filter.id, + value: filter.value[0] || '', + displayName: filter.value[0] || '', + })); + setAllFilters(initialFilters); + setChronologicalFilters(formattedInitialFilters); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + setFiltersLimitReached(chronologicalFilters.length >= MAX_TABLE_FILTERS_APPLIED); + if (onFilterChange) { + onFilterChange(state.filters.map((filter) => filter.id) || []); + } + }, [chronologicalFilters, onFilterChange, state.filters]); + + const availableFilters = columns.filter((column) => column.canFilter) + .sort((a, b) => (a.filterOrder || 0) - (b.filterOrder || 0)); + + const columnTextFilterHeaders = columns + .filter((column) => column.Filter === TextFilter) + .map((column) => column.Header); + + const getSearchPlaceholder = () => intl.formatMessage(messages['authz.table.controlbar.search.by.fields'], { + firstField: columnTextFilterHeaders[0] || '', + secondField: columnTextFilterHeaders[1] || '', + }); + + const handleCloseFilter = (filterName, filterValue) => { + const fiterGroup = state.filters.find((filter) => filter.id === filterName); + const newFilterValue = fiterGroup?.value.filter(item => item !== filterValue) || []; + setAllFilters(state.filters.map(item => (item.id !== filterName ? item : { id: item.id, value: newFilterValue }))); + setChronologicalFilters((prevFilters) => prevFilters.filter((filter) => filter.value !== filterValue)); + }; + + const handleSetFilters = (setFilter) => (allFilters: string[], newFilter: FilterChoice) => { + setFilter(allFilters); + setChronologicalFilters((prevFilters) => { + if (!prevFilters.find((filter) => filter.value === newFilter.value)) { + return [...prevFilters, newFilter]; + } + return prevFilters.filter((filter) => filter.value !== newFilter.value); + }); + }; + + const clearAllFilters = () => { + setAllFilters([]); + setChronologicalFilters([]); + }; + return ( +
+ + {availableFilters.map((column) => { + const { Filter } = column; + if (Filter === RolesFilter) { + return ( + + ); + } + if (Filter === OrgFilter) { + return ( + + ); + } + if (Filter === MultipleChoiceFilter) { + return ( + + ); + } + if (Filter === ScopesFilter) { + return ( + + ); + } + + if (Filter === TextFilter) { + return ( + + ); + } + return null; + })} + + + {chronologicalFilters.length > 0 && ( + + {intl.formatMessage(messages['authz.table.controlbar.filterby.label'])} + + {chronologicalFilters.map((filter) => { + const filterValue = typeof filter.value === 'string' ? filter.value : filter.value[0] || ''; + return ( + handleCloseFilter(filter.groupName, filter.value)} + > + {filterValue} + + ); + })} + {chronologicalFilters.length > 1 && ( + + )} + + )} + { filtersLimitReached && ( + + + + {intl.formatMessage(messages['authz.table.controlbar.filters.limit.reached'])} + + + )} + +
+ ); +}; + +export default TableControlBar; diff --git a/src/authz-module/components/TableControlBar/types.ts b/src/authz-module/components/TableControlBar/types.ts new file mode 100644 index 00000000..e30352a6 --- /dev/null +++ b/src/authz-module/components/TableControlBar/types.ts @@ -0,0 +1,23 @@ +export type FilterApplied = { + id: string; + value: string[]; +}; + +export type FilterChoice = { + groupName?: string; + groupIcon?: React.ComponentType<{}>; + displayName: string; + value: string; +}; + +export interface MultipleChoiceFilterProps { + filterButtonText: string; + filterChoices: Array; + filterValue: string[] | undefined; + setFilter: (value: string[], newItem: FilterChoice) => void; + isGrouped?: boolean; + isSearchable?: boolean; + onSearchChange?: (value: string) => void; + iconSrc?: React.ComponentType<{}> | undefined; + disabled?: boolean; +} diff --git a/src/authz-module/components/TableFooter/TableFooter.tsx b/src/authz-module/components/TableFooter/TableFooter.tsx new file mode 100644 index 00000000..30bc2fc9 --- /dev/null +++ b/src/authz-module/components/TableFooter/TableFooter.tsx @@ -0,0 +1,29 @@ +import React, { useContext } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { DataTableContext, Pagination, TableFooter } from '@openedx/paragon'; +import messages from '../messages'; + +const Footer = () => { + const { formatMessage } = useIntl(); + const { + pageCount, gotoPage, state, itemCount, + // @ts-ignore-next-line - Paragon's DataTableContext is not typed + } = useContext(DataTableContext); + const { pageIndex, pageSize } = state; + + return ( + + + {formatMessage(messages['authz.table.footer.items.showing.text'], { pageSize, itemCount })} + + gotoPage(pageNum - 1)} + /> + + ); +}; + +export default Footer; diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index 707dde7c..29b1d3d2 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -21,6 +21,52 @@ const messages = defineMessages({ defaultMessage: 'Scroll to top', description: 'Alt text for the scroll to top anchor button', }, + 'authz.table.controlbar.clearFilters': { + id: 'authz.table.controlbar.clearFilters', + defaultMessage: 'Clear filters', + description: 'Button to clear all active filters in the table', + }, + 'authz.table.controlbar.search': { + id: 'authz.table.controlbar.search', + defaultMessage: 'Search', + description: 'Search placeholder for two specific fields', + }, + 'authz.table.controlbar.search.by.fields': { + id: 'authz.table.controlbar.search.by.fields', + defaultMessage: 'Search by {firstField} or {secondField}', + description: 'Search placeholder for two specific fields', + }, + 'authz.table.controlbar.filterby.label': { + id: 'authz.table.controlbar.filterby.label', + defaultMessage: 'Filtered by: ', + description: 'Label for active filters in the table', + }, + 'authz.table.controlbar.filters.limit.reached': { + id: 'authz.table.controlbar.filters.limit.reached', + defaultMessage: '10 filter limit reached. Remove one of the applied filters so you can select another one.', + description: 'Message displayed when the user reaches the applied filters limit', + }, + 'authz.table.controlbar.filters.items.showing': { + id: 'authz.table.controlbar.filters.limit.reached', + defaultMessage: 'Showing {current} of {total}.', + description: 'Message displayed when the user reaches the applied filters limit', + }, + 'authz.table.footer.items.showing.text': { + id: 'authz.table.footer.items.showing.text', + defaultMessage: 'Showing {pageSize} of {itemCount} users.', + description: 'Message displayed when the user reaches the applied filters limit', + }, + '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', + defaultMessage: 'View', + description: 'Team members table view action text', + }, }); export default messages; diff --git a/src/authz-module/components/utils.tsx b/src/authz-module/components/utils.tsx new file mode 100644 index 00000000..4d0bd222 --- /dev/null +++ b/src/authz-module/components/utils.tsx @@ -0,0 +1,14 @@ +import { Icon } from '@openedx/paragon'; +import { FilterList } from '@openedx/paragon/icons'; + +export const getCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => { + if (filtersApplied.includes(columnId)) { + return ( + + + {columnTitle} + + ); + } + return columnTitle; +}; diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index b6f411ca..881666af 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -11,3 +11,5 @@ export enum RoleOperationErrorStatus { ROLE_ASSIGNMENT_ERROR = 'role_assignment_error', ROLE_REMOVAL_ERROR = 'role_removal_error', } + +export const MAX_TABLE_FILTERS_APPLIED = 10; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index bf5ff1ae..8b63fc9e 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -1,10 +1,14 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { LibraryMetadata, TeamMember } from '@src/types'; +import { + LibraryMetadata, Org, Scope, TeamMember, +} from '@src/types'; import { camelCaseObject } from '@edx/frontend-platform'; import { getApiUrl, getStudioApiUrl } from '@src/data/utils'; export interface QuerySettings { roles: string | null; + scopes: string | null; + organizations: string | null; search: string | null; order: string | null; sortBy: string | null; @@ -50,6 +54,14 @@ export interface AssignTeamMembersRoleRequest { scope: string; } +export interface GetOrgsResponse { + orgs: Array; +} + +export interface GetScopesResponse { + scopes: Scope[]; +} + export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); @@ -108,3 +120,34 @@ export const revokeUserRoles = async ( const res = await getAuthenticatedHttpClient().delete(url.toString()); return camelCaseObject(res.data); }; + +export const getOrgs = async (search?: string, page?: number, pageSize?: number): Promise => { + // TODO: verify the endpoint and response, this is expected to be used for organization filter choices + const url = new URL(getApiUrl('/api/authz/v1/orgs')); + if (search !== undefined) { + url.searchParams.set('search', search); + } + if (page !== undefined) { + url.searchParams.set('page', page.toString()); + } + if (pageSize !== undefined) { + url.searchParams.set('page_size', pageSize.toString()); + } + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); +}; + +export const getScopes = async (search?: string, page?: number, pageSize?: number): Promise => { + const url = new URL(getApiUrl('/api/authz/v1/scopes/')); + if (search !== undefined) { + url.searchParams.set('search', search); + } + if (page !== undefined) { + url.searchParams.set('page', page.toString()); + } + if (pageSize !== undefined) { + url.searchParams.set('page_size', pageSize.toString()); + } + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); +}; diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index bc5090e0..825430b1 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -4,7 +4,8 @@ import { import { appId } from '@src/constants'; import { LibraryMetadata } from '@src/types'; import { - assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, + assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getOrgs, GetOrgsResponse, + getPermissionsByRole, getScopes, GetScopesResponse, getTeamMembers, GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, } from './api'; @@ -15,6 +16,8 @@ const authzQueryKeys = { ...authzQueryKeys.teamMembersAll(scope), querySettings] as const, permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const, library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const, + orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const, + scopes: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'scopes', search, page, pageSize] as const, }; /** @@ -110,3 +113,28 @@ export const useRevokeUserRoles = () => { }, }); }; + +/** + * React query hook to fetch the list of organizations for the organization filter component. + * @param search - The search term to filter organizations. + * @returns The list of organizations matching the search term. + */ +export const useOrgs = (search?: string, page?: number, pageSize?: number) => { + const result = useQuery({ + queryKey: authzQueryKeys.orgs(search, page, pageSize), + queryFn: () => getOrgs(search, page, pageSize), + staleTime: 1000 * 60 * 30, // refetch after 30 minutes + refetchOnWindowFocus: false, + }); + return result; +}; + +export const useScopes = (search?: string, page?: number, pageSize?: number) => { + const result = useQuery({ + queryKey: authzQueryKeys.scopes(search, page, pageSize), + queryFn: () => getScopes(search, page, pageSize), + staleTime: 1000 * 60 * 30, // refetch after 30 minutes + refetchOnWindowFocus: false, + }); + return result; +}; diff --git a/src/authz-module/hooks/useQuerySettings.test.tsx b/src/authz-module/hooks/useQuerySettings.test.tsx new file mode 100644 index 00000000..6a9dc765 --- /dev/null +++ b/src/authz-module/hooks/useQuerySettings.test.tsx @@ -0,0 +1,447 @@ +import { renderHook, act } from '@testing-library/react'; +import { QuerySettings } from '@src/authz-module/data/api'; +import { useQuerySettings } from './useQuerySettings'; + +describe('useQuerySettings', () => { + const defaultQuerySettings: QuerySettings = { + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + sortBy: null, + order: null, + }; + + it('should initialize with default query settings when no initial settings provided', () => { + const { result } = renderHook(() => useQuerySettings()); + + expect(result.current.querySettings).toEqual(defaultQuerySettings); + expect(typeof result.current.handleTableFetch).toBe('function'); + }); + + it('should initialize with custom initial query settings', () => { + const customInitialSettings: QuerySettings = { + roles: 'admin,editor', + search: 'test-user', + pageSize: 20, + pageIndex: 2, + sortBy: 'username', + order: 'asc', + }; + + const { result } = renderHook(() => useQuerySettings(customInitialSettings)); + + expect(result.current.querySettings).toEqual(customInitialSettings); + }); + + it('should update query settings when handleTableFetch is called with new filters', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 15, + pageIndex: 1, + sortBy: [{ id: 'username', desc: false }], + filters: [ + { id: 'roles', value: ['admin', 'editor'] }, + { id: 'username', value: 'john' }, + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: 'admin,editor', + search: 'john', + pageSize: 15, + pageIndex: 1, + sortBy: 'username', + order: 'asc', + }); + }); + + it('should handle descending sort order by adding minus prefix', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'email', desc: true }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.order).toBe('desc'); + }); + + it('should convert camelCase sort field to snake_case', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'firstName', desc: false }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.sortBy).toBe('first_name'); + }); + + it('should convert camelCase sort field to snake_case with descending order', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'lastName', desc: true }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.order).toBe('desc'); + }); + + it('should handle empty filters by setting values to null', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + }); + }); + + it('should handle empty roles filter array by setting roles to null', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [ + { id: 'roles', value: [] }, + { id: 'username', value: '' }, + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + }); + }); + + it('should handle missing filters by setting default values', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [ + { id: 'roles', value: undefined }, + { id: 'username', value: undefined }, + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + }); + }); + + it('should use default pagination values when not provided', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + sortBy: [], + filters: [], + } as any; // Missing pageSize and pageIndex + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.pageSize).toBe(10); + expect(result.current.querySettings.pageIndex).toBe(0); + }); + + it('should not update state if settings have not changed', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [], + }; + + const initialSettings = result.current.querySettings; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + // Should be the same object reference since no changes occurred + expect(result.current.querySettings).toBe(initialSettings); + }); + + it('should update state when settings have changed', () => { + const { result } = renderHook(() => useQuerySettings()); + + const initialSettings = result.current.querySettings; + + const tableFilters = { + pageSize: 20, // Different from default + pageIndex: 0, + sortBy: [], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + // Should be a different object reference since pageSize changed + expect(result.current.querySettings).not.toBe(initialSettings); + expect(result.current.querySettings.pageSize).toBe(20); + }); + + it('should handle complex filter combinations', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 25, + pageIndex: 3, + sortBy: [{ id: 'userRole', desc: true }], + filters: [ + { id: 'roles', value: ['admin', 'editor', 'viewer'] }, + { id: 'username', value: 'test@example.com' }, + { id: 'otherFilter', value: 'ignored' }, // Should be ignored + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: 'admin,editor,viewer', + search: 'test@example.com', + pageSize: 25, + pageIndex: 3, + order: 'desc', + sortBy: 'user_role', + }); + }); + + it('should handle multiple camelCase words in sort field', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'userFirstLastName', desc: false }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.sortBy).toBe('user_first_last_name'); + }); + + it('should preserve handleTableFetch function reference across renders', () => { + const { result, rerender } = renderHook(() => useQuerySettings()); + + const initialHandleTableFetch = result.current.handleTableFetch; + + rerender(); + + expect(result.current.handleTableFetch).toBe(initialHandleTableFetch); + }); + + it('should handle whitespace-only search values as provided', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [ + { id: 'username', value: ' ' }, // Whitespace only + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.search).toBe(' '); + }); + + it('should detect changes in roles filter', () => { + const { result } = renderHook(() => useQuerySettings()); + + // First set some roles + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'roles', value: ['admin'] }], + }); + }); + + const settingsAfterFirstUpdate = result.current.querySettings; + + // Then change roles + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'roles', value: ['editor'] }], + }); + }); + + expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); + expect(result.current.querySettings.roles).toBe('editor'); + }); + + it('should detect changes in search filter', () => { + const { result } = renderHook(() => useQuerySettings()); + + // First set a search term + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'username', value: 'john' }], + }); + }); + + const settingsAfterFirstUpdate = result.current.querySettings; + + // Then change search term + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'username', value: 'jane' }], + }); + }); + + expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); + expect(result.current.querySettings.search).toBe('jane'); + }); + + it('should detect changes in ordering', () => { + const { result } = renderHook(() => useQuerySettings()); + + // First set ordering + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'username', desc: false }], + filters: [], + }); + }); + + const settingsAfterFirstUpdate = result.current.querySettings; + + // Then change ordering + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'email', desc: true }], + filters: [], + }); + }); + + expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); + expect(result.current.querySettings.sortBy).toBe('email'); + expect(result.current.querySettings.order).toBe('desc'); + }); + + it('should detect changes in pageSize', () => { + const { result } = renderHook(() => useQuerySettings()); + + const initialSettings = result.current.querySettings; + + act(() => { + result.current.handleTableFetch({ + pageSize: 50, + pageIndex: 0, + sortBy: [], + filters: [], + }); + }); + + expect(result.current.querySettings).not.toBe(initialSettings); + expect(result.current.querySettings.pageSize).toBe(50); + }); + + it('should detect changes in pageIndex', () => { + const { result } = renderHook(() => useQuerySettings()); + + const initialSettings = result.current.querySettings; + + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 5, + sortBy: [], + filters: [], + }); + }); + + expect(result.current.querySettings).not.toBe(initialSettings); + expect(result.current.querySettings.pageIndex).toBe(5); + }); +}); diff --git a/src/authz-module/hooks/useQuerySettings.tsx b/src/authz-module/hooks/useQuerySettings.tsx new file mode 100644 index 00000000..505d96be --- /dev/null +++ b/src/authz-module/hooks/useQuerySettings.tsx @@ -0,0 +1,96 @@ +import { useCallback, useState } from 'react'; +import { QuerySettings } from '@src/authz-module/data/api'; + +interface DataTableFilters { + pageSize: number; + pageIndex: number; + sortBy: Array<{ id: string; desc: boolean }>; + filters: Array<{ id: string; value: any }>; +} + +interface UseQuerySettingsReturn { + querySettings: QuerySettings; + handleTableFetch: (tableFilters: DataTableFilters) => void; +} + +enum SortOrderKeys { + ASC = 'asc', + DESC = 'desc', +} + +/** + * Custom hook to manage query settings for table data fetching + * Converts DataTable filter/sort/pagination settings to API query parameters + * and manages URL synchronization + * + * @param initialQuerySettings - Initial query settings + * @returns Object containing querySettings and handleTableFetch function + */ +export const useQuerySettings = ( + initialQuerySettings: QuerySettings = { + roles: null, + scopes: null, + organizations: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + }, +): UseQuerySettingsReturn => { + const [querySettings, setQuerySettings] = useState(initialQuerySettings); + + const handleTableFetch = useCallback((tableFilters: DataTableFilters) => { + setQuerySettings((prevSettings) => { + // Extract filters + const rolesFilter = tableFilters.filters?.find((filter) => filter.id === 'role')?.value?.join(',') ?? ''; + const searchFilter = tableFilters.filters?.find((filter) => filter.id === 'username')?.value ?? ''; + const organizationsFilter = tableFilters.filters?.find((filter) => filter.id === 'organization')?.value?.join(',') ?? ''; + const scopesFilter = tableFilters.filters?.find((filter) => filter.id === 'scope')?.value?.join(',') ?? ''; + + // Extract pagination + const { pageSize = 10, pageIndex = 0 } = tableFilters; + + // Extract and convert sorting + let sortByOption = ''; + let sortByOrder = ''; + if (tableFilters.sortBy?.length) { + sortByOption = tableFilters.sortBy[0]?.id.replace(/([A-Z])/g, '_$1').toLowerCase(); + sortByOrder = tableFilters.sortBy[0]?.desc ? SortOrderKeys.DESC : SortOrderKeys.ASC; + } + + const newQuerySettings: QuerySettings = { + roles: rolesFilter || null, + scopes: scopesFilter || null, + organizations: organizationsFilter || null, + search: searchFilter || null, + sortBy: sortByOption || null, + order: sortByOrder || null, + pageSize, + pageIndex, + }; + + const hasChanged = ( + prevSettings.roles !== newQuerySettings.roles + || prevSettings.scopes !== newQuerySettings.scopes + || prevSettings.organizations !== newQuerySettings.organizations + || prevSettings.search !== newQuerySettings.search + || prevSettings.pageSize !== newQuerySettings.pageSize + || prevSettings.pageIndex !== newQuerySettings.pageIndex + || prevSettings.sortBy !== newQuerySettings.sortBy + || prevSettings.order !== newQuerySettings.order + ); + + if (!hasChanged) { + return prevSettings; // No change, prevent unnecessary update + } + + return newQuerySettings; + }); + }, []); + + return { + querySettings, + handleTableFetch, + }; +}; diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 4c6ef34f..34f941ac 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -1,19 +1,20 @@ @use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints; -.authz-libraries { +.authz-module { --height-action-divider: 30px; - hr { - border-top: var(--pgn-size-border-width) solid var(--pgn-color-border); - width: 100%; + .filters .dropdown-toggle::after { + display: none !important; + } + .pgn__data-table tr:has(td[data-role="Super Admin"]), + .pgn__data-table tr:has(td[data-role="Global Staff"]) { + background-color: var(--pgn-color-primary-200); } - @media (--pgn-size-breakpoint-min-width-lg) { - hr { - border-right: var(--pgn-size-border-width) solid var(--pgn-color-border); - height: var(--height-action-divider); - width: 0; - } + hr { + border-right: var(--pgn-size-border-width) solid var(--pgn-color-border); + height: var(--height-action-divider); + width: 0; } .tab-content { diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts new file mode 100644 index 00000000..88229f8c --- /dev/null +++ b/src/authz-module/messages.ts @@ -0,0 +1,13 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages( + { + 'authz.management.assign.role.title': { + id: 'authz.management.assign.role.title', + defaultMessage: 'Assign Role', + description: 'Text for the assign role button', + }, + }, +); + +export default messages; diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx new file mode 100644 index 00000000..406fb7b1 --- /dev/null +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -0,0 +1,237 @@ +import { useEffect, useMemo, useState } from 'react'; +import debounce from 'lodash.debounce'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + DataTable, + TextFilter, +} from '@openedx/paragon'; + +import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext'; +import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; +import { TeamMember } from 'types'; +import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter'; +import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilter'; +import ScopesFilter from '@src/authz-module/components/TableControlBar/ScopesFilter'; +import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar'; +import { getCellHeader } from 'authz-module/components/utils'; +import { + ActionCell, NameCell, RoleCell, ScopeCell, +} from '@src/authz-module/components/TableCells'; +import messages from './messages'; +import TableFooter from '../components/TableFooter/TableFooter'; + +const DEFAULT_PAGE_SIZE = 10; +// TODO: use the actual data from the API +const teamMembersMockedList: TeamMember[] = [ + { + username: 'admin', + fullName: 'Alice Johnson', + email: 'alice.johnson@example.edu', + createdAt: '2024-01-15T08:30:00Z', + scope: { resource: 'CS101', type: 'COURSE' }, + organization: 'MIT', + role: 'Course Admin', + roles: [], + }, + { + username: 'bob.smith', + fullName: 'Robert Smith', + email: 'bob.smith@university.org', + createdAt: '2024-02-10T14:22:00Z', + scope: { resource: 'math-library', type: 'LIBRARY' }, + organization: 'Stanford', + role: 'Library Author', + roles: [], + }, + { + username: 'carol.davis', + fullName: 'Carol Davis', + email: 'c.davis@adminpanel.edu', + createdAt: '2023-12-05T09:15:00Z', + scope: { resource: 'system', type: 'GLOBAL' }, + organization: 'Harvard', + role: 'Super Admin', + roles: [], + }, + { + username: 'david.wilson', + fullName: 'David Wilson', + email: 'david.w@teaching.com', + createdAt: '2024-03-01T16:45:00Z', + scope: { resource: 'PHYS201', type: 'COURSE' }, + organization: 'Caltech', + role: 'Course Staff', + roles: [], + }, + { + username: 'emma.brown', + fullName: 'Emma Brown', + email: 'emma.brown@lib.edu', + createdAt: '2024-01-28T11:30:00Z', + scope: { resource: 'science-resources', type: 'LIBRARY' }, + organization: 'Berkeley', + role: 'Library Admin', + roles: [], + }, + { + username: 'frank.miller', + fullName: 'Franklin Miller', + email: 'f.miller@global.org', + createdAt: '2023-11-20T13:00:00Z', + scope: { resource: 'platform', type: 'GLOBAL' }, + organization: 'Yale', + role: 'Global Staff', + roles: [], + }, + { + username: 'grace.lee', + fullName: 'Grace Lee', + email: 'grace.lee@courses.edu', + createdAt: '2024-02-14T10:15:00Z', + scope: { resource: 'HIST150', type: 'COURSE' }, + organization: 'Princeton', + role: 'Course Admin', + roles: [], + }, + { + username: 'henry.taylor', + fullName: 'Henry Taylor', + email: 'h.taylor@library.net', + createdAt: '2024-01-08T15:20:00Z', + scope: { resource: 'literature-collection', type: 'LIBRARY' }, + organization: 'Columbia', + role: 'Library Author', + roles: [], + }, + { + username: 'isabel.garcia', + fullName: 'Isabel Garcia', + email: 'i.garcia@admin.edu', + createdAt: '2023-10-12T07:45:00Z', + scope: { resource: 'system', type: 'GLOBAL' }, + organization: 'MIT', + role: 'Super Admin', + roles: [], + }, + { + username: 'jack.anderson', + fullName: 'Jack Anderson', + email: 'jack.a@support.com', + createdAt: '2024-02-25T12:10:00Z', + scope: { resource: 'CHEM301', type: 'COURSE' }, + organization: 'Northwestern', + role: 'Course Staff', + roles: [], + }, +]; + +interface TeamMembersTableProps { + presetScope?: string; +} + +const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { + const intl = useIntl(); + const { showErrorToast } = useToastManager(); + const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState([]); + + // TODO: add querySettings to the dependencies of useTeamMembers and handleTableFetch once the API integration is done + // const { querySettings, handleTableFetch } = useQuerySettings(); + const { handleTableFetch } = useQuerySettings(); + + // TODO: use the actual data from the API once the integration is done + /* const { + data: teamMembers, isError, error, refetch, + } = useTeamMembers(libraryId, querySettings); + */ + const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; + const error = null; + const refetch = () => {}; + + if (error) { + showErrorToast(error, refetch); + } + + // const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS); + const pageCount = teamMembersMockedList?.length ? Math.ceil(teamMembersMockedList.length / DEFAULT_PAGE_SIZE) : 1; + + const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]); + + useEffect(() => () => fetchData.cancel(), [fetchData]); + + return ( +
+ + + + + +
+ ); +}; + +export default TeamMembersTable; diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts new file mode 100644 index 00000000..30eebf70 --- /dev/null +++ b/src/authz-module/team-members/messages.ts @@ -0,0 +1,37 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +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', + }, + 'authz.team.members.table.column.email.title': { + id: 'authz.team.members.table.column.email.title', + defaultMessage: 'Email', + description: 'Team members table email column header', + }, + 'authz.team.members.table.column.organization.title': { + id: 'authz.team.members.table.column.organization.title', + defaultMessage: 'Organization', + description: 'Team members table organization column header', + }, + 'authz.team.members.table.column.scope.title': { + id: 'authz.team.members.table.column.scope.title', + defaultMessage: 'Scope', + description: 'Team members table scope column header', + }, + 'authz.team.members.table.column.role.title': { + id: 'authz.team.members.table.column.role.title', + defaultMessage: 'Role', + description: 'Team members table role column header', + }, + 'authz.team.members.table.column.actions.title': { + id: 'authz.team.members.table.column.actions.title', + defaultMessage: 'Actions', + description: 'Team members table actions column header', + }, + +}); + +export default messages; diff --git a/src/types.ts b/src/types.ts index 27d78f9f..ef67c453 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,8 +12,12 @@ export interface TeamMember { username: string; fullName: string; email: string; + // TODO: remove when library team members get removed roles: string[]; createdAt: string; + scope: { resource: string, type: 'COURSE' | 'LIBRARY' | 'GLOBAL' }; + organization: string; + role: string; } export interface LibraryMetadata { @@ -49,6 +53,18 @@ export type PermissionMetadata = { description?: string; }; +export type Org = { + id: string; + name: string; +}; + +export type Scope = { + key: string; + name: string; + description: string; + organization: Org; +}; + // Permissions Matrix export type EnrichedPermission = PermissionMetadata & { @@ -84,3 +100,10 @@ export interface TableCellValue { original: T; }; } + +export type AppContextType = { + authenticatedUser: { + username: string; + email: string; + }; +}; From ad413a317a1e3aaa3373b31ed83fd900cb1fe6b5 Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Wed, 15 Apr 2026 14:04:56 -0600 Subject: [PATCH 11/15] feat: integrating backend apis into team members table --- src/authz-module/authz-home/index.tsx | 4 +- src/authz-module/components/TableCells.tsx | 59 +++++-- .../TableControlBar/MultipleChoiceFilter.tsx | 22 +-- .../components/TableControlBar/OrgFilter.tsx | 13 +- .../TableControlBar/RolesFilter.tsx | 47 +---- .../TableControlBar/ScopesFilter.tsx | 24 ++- .../TableControlBar/TableControlBar.tsx | 50 +++--- .../components/TableControlBar/types.ts | 5 - .../components/TableFooter/TableFooter.tsx | 7 +- src/authz-module/components/constants.ts | 74 ++++++++ src/authz-module/components/messages.ts | 25 +++ src/authz-module/constants.ts | 17 ++ src/authz-module/data/api.ts | 48 ++++- src/authz-module/data/hooks.ts | 37 +++- src/authz-module/hooks/useQuerySettings.tsx | 4 +- .../AddNewTeamMemberTrigger.tsx | 2 +- .../components/TeamTable/index.tsx | 7 +- .../team-members/TeamMembersTable.tsx | 167 ++++-------------- src/types.ts | 20 ++- 19 files changed, 369 insertions(+), 263 deletions(-) create mode 100644 src/authz-module/components/constants.ts diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx index f7dea5b7..6537e28c 100644 --- a/src/authz-module/authz-home/index.tsx +++ b/src/authz-module/authz-home/index.tsx @@ -1,7 +1,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Tab, Tabs } from '@openedx/paragon'; import { useLocation, useSearchParams } from 'react-router-dom'; -import TeamMembersTable from 'authz-module/team-members/TeamMembersTable'; +import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable'; import AddRoleButton from '@src/authz-module/components/AddRoleButton'; import RolesPermissions from '../roles-permissions/RolesPermissions'; import AuthZLayout from '../components/AuthZLayout'; @@ -34,7 +34,7 @@ const AuthzHome = () => { defaultActiveKey={hash ? 'permissionsRoles' : 'team'} className="bg-light-100 px-5" > - + diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 84d97211..36fd9c4c 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -2,27 +2,25 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, IconButton } from '@openedx/paragon'; import { AppContext } from '@edx/frontend-platform/react'; import { - RemoveRedEye, Language, School, LibraryBooks, + RemoveRedEye, } from '@openedx/paragon/icons'; -import { TableCellValue, TeamMember, AppContextType } from '@src/types'; +import { TableCellValue, AppContextType, UserRole } from '@src/types'; import { useNavigate } from 'react-router-dom'; -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; +import { DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants'; import messages from './messages'; +import { RESOURCE_ICONS } from './constants'; -type CellProps = TableCellValue; -type ExtendedCellProps = CellProps & { +type CellProps = TableCellValue; +type CellPropsWithValue = CellProps & { value: string; +}; +type ExtendedCellProps = CellPropsWithValue & { cell: { getCellProps: (props?: Record) => Record; }; }; -const SCOPE_ICONS = { - COURSE: School, - LIBRARY: LibraryBooks, - GLOBAL: Language, -}; - const NameCell = ({ row }: CellProps) => { const intl = useIntl(); const { authenticatedUser } = useContext(AppContext) as AppContextType; @@ -31,12 +29,12 @@ const NameCell = ({ row }: CellProps) => { if (row.original.username === username) { return ( - {row.original.fullName} + {row.original.fullName || row.original.username} {intl.formatMessage(messages['authz.table.username.current'])} ); } - return row.original.fullName; + return row.original.fullName || row.original.username; }; const ActionCell = ({ row }: CellProps) => { @@ -53,23 +51,46 @@ const ActionCell = ({ row }: CellProps) => { ); }; +const OrgCell = ({ value, row }: CellPropsWithValue) => { + const { formatMessage } = useIntl(); + return ( + + {DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value} + + ); +}; + const ScopeCell = ({ row }: CellProps) => { - const { scope } = row.original; - const iconSrc = SCOPE_ICONS[scope.type]; + const { formatMessage } = useIntl(); + + const { scopeText, iconSrc } = useMemo(() => { + if (DJANGO_MANAGED_ROLES.includes(row.original.role)) { + return { + scopeText: formatMessage(messages['authz.user.table.scope.global.label']), + iconSrc: RESOURCE_ICONS.GLOBAL, + }; + } + const scopeIcon = row.original.role.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + return { + scopeText: row.original.scope, + iconSrc: scopeIcon, + }; + }, [row.original.role, row.original.scope, formatMessage]); + return ( {iconSrc && } - {scope.resource} + {scopeText} ); }; const RoleCell = ({ value, cell }: ExtendedCellProps) => ( - - {value} + + {MAP_ROLE_KEY_TO_LABEL[value] || ''} ); export { - NameCell, ActionCell, ScopeCell, RoleCell, + NameCell, ActionCell, ScopeCell, RoleCell, OrgCell, }; diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx index 59bf4642..8c6ca8fc 100644 --- a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx +++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx @@ -20,19 +20,20 @@ const MultipleChoiceFilter = ({ }: MultipleChoiceFilterProps) => { const [searchValue, setSearchValue] = useState(undefined); const { formatMessage } = useIntl(); + const checkedBoxes = filterValue || []; - const handleClickCheckbox = (value) => { + const handleClickCheckbox = (value, displayName) => { const newValue = { groupName: filterButtonText?.toLocaleLowerCase() || '', value, - displayName: value, + displayName, }; if (checkedBoxes.includes(value)) { const newCheckedBoxes = checkedBoxes.filter((val) => val !== value); return setFilter(newCheckedBoxes, newValue); } - checkedBoxes.push(value); - return setFilter(checkedBoxes, newValue); + const newCheckedBoxes = [...checkedBoxes, value]; + return setFilter(newCheckedBoxes, newValue); }; const getGroupedChoices = () => { @@ -81,17 +82,18 @@ const MultipleChoiceFilter = ({ aria-label={filterButtonText} value={checkedBoxes} > - {/** TODO: Change for actual values */} - {formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })} + + {formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })} + {!isGrouped ? filterChoices.map(({ displayName, value, }) => ( handleClickCheckbox(value)} + onChange={() => handleClickCheckbox(value, displayName)} aria-label={displayName} disabled={checkedBoxes.includes(value) ? false : disabled} > @@ -106,10 +108,10 @@ const MultipleChoiceFilter = ({ {options.map(({ displayName, value }) => ( handleClickCheckbox(value)} + onChange={() => handleClickCheckbox(value, displayName)} disabled={checkedBoxes.includes(value) ? false : disabled} aria-label={displayName} > diff --git a/src/authz-module/components/TableControlBar/OrgFilter.tsx b/src/authz-module/components/TableControlBar/OrgFilter.tsx index 94822c2a..89c00b87 100644 --- a/src/authz-module/components/TableControlBar/OrgFilter.tsx +++ b/src/authz-module/components/TableControlBar/OrgFilter.tsx @@ -10,12 +10,15 @@ const OrgFilter = ({ filterButtonText, filterValue, setFilter, disabled, }: OrgFilterProps) => { const [searchValue, setSearchValue] = React.useState(undefined); - const { data: orgsData = { orgs: [] } } = useOrgs(searchValue); - - const filterChoices = useMemo(() => orgsData.orgs.map((org) => ({ + const { + data: orgsData = { + count: 0, next: null, previous: null, results: [], + }, + } = useOrgs(searchValue); + const filterChoices = useMemo(() => orgsData?.results?.map((org) => ({ displayName: org.name, - value: org.id, - })), [orgsData]); + value: org.shortName, + })) || [], [orgsData]); const handleSearchChange = (value: string) => { setSearchValue(value); diff --git a/src/authz-module/components/TableControlBar/RolesFilter.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx index 46628e9d..d0e061e9 100644 --- a/src/authz-module/components/TableControlBar/RolesFilter.tsx +++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx @@ -1,54 +1,21 @@ -import React, { useMemo } from 'react'; -import { - Person, Language, School, LibraryBooks, -} from '@openedx/paragon/icons'; +import { useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Person } from '@openedx/paragon/icons'; import MultipleChoiceFilter from './MultipleChoiceFilter'; import { MultipleChoiceFilterProps } from './types'; +import { getRolesFiltersOptions } from '../constants'; type RolesFilterProps = Omit; const RolesFilter = ({ filterButtonText, filterValue, setFilter, disabled, }: RolesFilterProps) => { - // TODO: use a constant - const filterChoices = useMemo(() => [ - { - groupName: 'Global', groupIcon: Language, displayName: 'Super Admin', value: 'Super Admin', - }, - { - groupName: 'Global', groupIcon: Language, displayName: 'Global Staff', value: 'Global Staff', - }, - - { - groupName: 'Course', groupIcon: School, displayName: 'Course Admin', value: 'Course Admin', - }, - { - groupName: 'Course', groupIcon: School, displayName: 'Course Staff', value: 'Course Staff', - }, - { - groupName: 'Course', groupIcon: School, displayName: 'Course Editor', value: 'Course Editor', - }, - { - groupName: 'Course', groupIcon: School, displayName: 'Course Auditor', value: 'Course Auditor', - }, - - { - groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Admin', value: 'Library Admin', - }, - { - groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Author', value: 'Library Author', - }, - { - groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Collaborator', value: 'Library Collaborator', - }, - { - groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library User', value: 'Library User', - }, - ], []); + const intl = useIntl(); + const rolesOptions = useMemo(() => getRolesFiltersOptions(intl), [intl]); return ( ; const ScopesFilter = ({ filterButtonText, filterValue, setFilter, disabled, }: ScopesFilterProps) => { + const { formatMessage } = useIntl(); const [searchValue, setSearchValue] = React.useState(undefined); - const { data: scopesData = { scopes: [] } } = useScopes(searchValue); + const { data: scopesData = { results: [] } } = useScopes(searchValue); - const filterChoices = useMemo(() => scopesData.scopes.map((scope) => ({ - displayName: scope.name, - value: scope.key, - groupName: scope.organization.name, - })), [scopesData]); + const filterChoices = useMemo(() => scopesData.results.map((scope) => { + const scopeIcon = scope.externalKey.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + let groupName = formatMessage(messages['authz.team.members.table.group.courses']); + if (scope.externalKey.startsWith('lib')) { + groupName = formatMessage(messages['authz.team.members.table.group.libraries']); + } + return { + displayName: scope.displayName, + value: scope.externalKey, + groupName, + groupIcon: scopeIcon, + }; + }), [scopesData, formatMessage]); const handleSearchChange = (value: string) => { setSearchValue(value); diff --git a/src/authz-module/components/TableControlBar/TableControlBar.tsx b/src/authz-module/components/TableControlBar/TableControlBar.tsx index 4944208f..3d937c0b 100644 --- a/src/authz-module/components/TableControlBar/TableControlBar.tsx +++ b/src/authz-module/components/TableControlBar/TableControlBar.tsx @@ -21,7 +21,7 @@ import messages from '../messages'; import RolesFilter from './RolesFilter'; import OrgFilter from './OrgFilter'; import ScopesFilter from './ScopesFilter'; -import { FilterApplied, FilterChoice } from './types'; +import { FilterChoice } from './types'; const FILTER_CHIPS_ICONS = { role: Person, @@ -29,12 +29,17 @@ const FILTER_CHIPS_ICONS = { scope: LocationOn, }; +const FILTER_GROUP_TO_ID = { + role: 'role', + organization: 'org', + scope: 'scope', +}; + interface TableControlBarProps { onFilterChange?: (filters: string[]) => void; - initialFilters?: FilterApplied[]; } -const TableControlBar = ({ onFilterChange, initialFilters }: TableControlBarProps) => { +const TableControlBar = ({ onFilterChange }: TableControlBarProps) => { const intl = useIntl(); // applied filters in the order they were selected by the user, to display on the control bar as chips const [chronologicalFilters, setChronologicalFilters] = useState([]); @@ -47,13 +52,12 @@ const TableControlBar = ({ onFilterChange, initialFilters }: TableControlBarProp } = useContext(DataTableContext); useEffect(() => { - if (initialFilters) { - const formattedInitialFilters = initialFilters.map((filter) => ({ + if (state.filters.length > 0) { + const formattedInitialFilters = state.filters.map((filter) => ({ groupName: filter.id, value: filter.value[0] || '', displayName: filter.value[0] || '', })); - setAllFilters(initialFilters); setChronologicalFilters(formattedInitialFilters); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -79,9 +83,11 @@ const TableControlBar = ({ onFilterChange, initialFilters }: TableControlBarProp }); const handleCloseFilter = (filterName, filterValue) => { - const fiterGroup = state.filters.find((filter) => filter.id === filterName); - const newFilterValue = fiterGroup?.value.filter(item => item !== filterValue) || []; - setAllFilters(state.filters.map(item => (item.id !== filterName ? item : { id: item.id, value: newFilterValue }))); + const actualFilterId = FILTER_GROUP_TO_ID[filterName] || filterName; + const filterGroup = state.filters.find((filter) => filter.id === actualFilterId); + const newFilterValue = filterGroup?.value.filter(item => item !== filterValue) || []; + setAllFilters(state.filters.map(item => ( + item.id !== actualFilterId ? item : { id: item.id, value: newFilterValue }))); setChronologicalFilters((prevFilters) => prevFilters.filter((filter) => filter.value !== filterValue)); }; @@ -137,6 +143,7 @@ const TableControlBar = ({ onFilterChange, initialFilters }: TableControlBarProp {...column} setFilter={handleSetFilters(column.setFilter)} disabled={filtersLimitReached} + filterValue={state.filters.find(filter => filter.id === 'scope')?.value || null} /> ); } @@ -159,21 +166,18 @@ const TableControlBar = ({ onFilterChange, initialFilters }: TableControlBarProp {intl.formatMessage(messages['authz.table.controlbar.filterby.label'])} - {chronologicalFilters.map((filter) => { - const filterValue = typeof filter.value === 'string' ? filter.value : filter.value[0] || ''; - return ( - handleCloseFilter(filter.groupName, filter.value)} - > - {filterValue} - - ); - })} + {chronologicalFilters.map((filter) => ( + handleCloseFilter(filter.groupName, filter.value)} + > + {filter.displayName} + + ))} {chronologicalFilters.length > 1 && ( - )} diff --git a/src/authz-module/components/TableControlBar/types.ts b/src/authz-module/components/TableControlBar/types.ts index e30352a6..b6ba740e 100644 --- a/src/authz-module/components/TableControlBar/types.ts +++ b/src/authz-module/components/TableControlBar/types.ts @@ -1,8 +1,3 @@ -export type FilterApplied = { - id: string; - value: string[]; -}; - export type FilterChoice = { groupName?: string; groupIcon?: React.ComponentType<{}>; diff --git a/src/authz-module/components/TableFooter/TableFooter.tsx b/src/authz-module/components/TableFooter/TableFooter.tsx index 30bc2fc9..26b3a974 100644 --- a/src/authz-module/components/TableFooter/TableFooter.tsx +++ b/src/authz-module/components/TableFooter/TableFooter.tsx @@ -6,15 +6,14 @@ import messages from '../messages'; const Footer = () => { const { formatMessage } = useIntl(); const { - pageCount, gotoPage, state, itemCount, + pageCount, gotoPage, state, itemCount, rows, // @ts-ignore-next-line - Paragon's DataTableContext is not typed } = useContext(DataTableContext); - const { pageIndex, pageSize } = state; - + const { pageIndex } = state; return ( - {formatMessage(messages['authz.table.footer.items.showing.text'], { pageSize, itemCount })} + {formatMessage(messages['authz.table.footer.items.showing.text'], { pageSize: rows.length, itemCount })} [ + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.global']), + groupIcon: Language, + displayName: 'Super Admin', + value: 'super_admin', + }, + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.global']), + groupIcon: Language, + displayName: 'Global Staff', + value: 'global_staff', + }, + + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), + groupIcon: School, + displayName: 'Course Admin', + value: 'course_admin', + }, + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), + groupIcon: School, + displayName: 'Course Staff', + value: 'course_staff', + }, + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), + groupIcon: School, + displayName: 'Course Editor', + value: 'course_editor', + }, + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), + groupIcon: School, + displayName: 'Course Auditor', + value: 'course_auditor', + }, + + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), + groupIcon: LibraryBooks, + displayName: 'Library Admin', + value: 'library_admin', + }, + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), + groupIcon: LibraryBooks, + displayName: 'Library Author', + value: 'library_author', + }, + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), + groupIcon: LibraryBooks, + displayName: 'Library Collaborator', + value: 'library_collaborator', + }, + { + groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), + groupIcon: LibraryBooks, + displayName: 'Library User', + value: 'library_user', + }, +]; + +export const RESOURCE_ICONS = { + COURSE: School, + LIBRARY: LibraryBooks, + GLOBAL: Language, +}; diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index 29b1d3d2..05988fdc 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -26,11 +26,21 @@ const messages = defineMessages({ defaultMessage: 'Clear filters', description: 'Button to clear all active filters in the table', }, + '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.', + }, 'authz.table.controlbar.search': { id: 'authz.table.controlbar.search', defaultMessage: 'Search', description: 'Search placeholder for two specific fields', }, + 'authz.user.table.scope.global.label': { + id: 'authz.user.table.scope.global.label', + defaultMessage: 'Global', + description: 'Label for the "Global" scope in the user assignments table when a user has a django managed role assigned.', + }, 'authz.table.controlbar.search.by.fields': { id: 'authz.table.controlbar.search.by.fields', defaultMessage: 'Search by {firstField} or {secondField}', @@ -67,6 +77,21 @@ const messages = defineMessages({ defaultMessage: 'View', description: 'Team members table view action text', }, + 'authz.team.members.table.group.courses': { + id: 'authz.team.members.table.group.courses', + defaultMessage: 'Courses', + description: 'Label for the "Courses" group in the team members table filters', + }, + 'authz.team.members.table.group.libraries': { + id: 'authz.team.members.table.group.libraries', + defaultMessage: 'Libraries', + description: 'Label for the "Libraries" group in the team members table filters', + }, + 'authz.team.members.table.group.global': { + id: 'authz.team.members.table.group.global', + defaultMessage: 'Global', + description: 'Label for the "Global" group in the team members table filters', + }, }); export default messages; diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 881666af..9145f78c 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -13,3 +13,20 @@ export enum RoleOperationErrorStatus { } export const MAX_TABLE_FILTERS_APPLIED = 10; + +export const MAP_ROLE_KEY_TO_LABEL: Record = { + library_admin: 'Library Admin', + library_author: 'Library Author', + library_contributor: 'Library Contributor', + library_user: 'Library User', + course_admin: 'Course Admin', + course_staff: 'Course Staff', + course_editor: 'Course Editor', + course_auditor: 'Course Auditor', + 'django.superuser': 'Super Admin', + 'django.globalstaff': 'Global Staff', +}; + +export const DJANGO_MANAGED_ROLES = ['django.superuser', 'django.globalstaff']; + +export const TABLE_DEFAULT_PAGE_SIZE = 10; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 8b63fc9e..d5eb8437 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -1,6 +1,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { LibraryMetadata, Org, Scope, TeamMember, + UserRole, } from '@src/types'; import { camelCaseObject } from '@edx/frontend-platform'; import { getApiUrl, getStudioApiUrl } from '@src/data/utils'; @@ -54,12 +55,25 @@ export interface AssignTeamMembersRoleRequest { scope: string; } +export interface GetAllRoleAssignmentsResponse { + results: UserRole[]; + count: number; + next: string | null; + previous: string | null; +} + export interface GetOrgsResponse { - orgs: Array; + count: number; + next: string | null; + previous: string | null; + results:Array; } export interface GetScopesResponse { - scopes: Scope[]; + count: number; + next: string | null; + previous: string | null; + results:Array; } export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { @@ -121,9 +135,35 @@ export const revokeUserRoles = async ( return camelCaseObject(res.data); }; +export const getAllRoleAssignments = async (querySettings: QuerySettings) +: Promise => { + const url = new URL(getApiUrl('/api/authz/v1/assignments/')); + + if (querySettings.roles) { + url.searchParams.set('roles', querySettings.roles); + } + if (querySettings.scopes) { + url.searchParams.set('scopes', querySettings.scopes); + } + if (querySettings.organizations) { + url.searchParams.set('orgs', querySettings.organizations); + } + if (querySettings.search) { + url.searchParams.set('search', querySettings.search); + } + if (querySettings.sortBy && querySettings.order) { + url.searchParams.set('sort_by', querySettings.sortBy); + url.searchParams.set('order', querySettings.order); + } + url.searchParams.set('page_size', querySettings.pageSize.toString()); + url.searchParams.set('page', (querySettings.pageIndex + 1).toString()); + + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); +}; + export const getOrgs = async (search?: string, page?: number, pageSize?: number): Promise => { - // TODO: verify the endpoint and response, this is expected to be used for organization filter choices - const url = new URL(getApiUrl('/api/authz/v1/orgs')); + const url = new URL(getApiUrl('/api/authz/v1/orgs/')); if (search !== undefined) { url.searchParams.set('search', search); } diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 825430b1..8b0cfa1d 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -4,9 +4,11 @@ import { import { appId } from '@src/constants'; import { LibraryMetadata } from '@src/types'; import { - assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getOrgs, GetOrgsResponse, + assignTeamMembersRole, AssignTeamMembersRoleRequest, getAllRoleAssignments, + GetAllRoleAssignmentsResponse, getLibrary, getOrgs, GetOrgsResponse, getPermissionsByRole, getScopes, GetScopesResponse, getTeamMembers, - GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, + GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, + RevokeUserRolesRequest, } from './api'; const authzQueryKeys = { @@ -16,6 +18,7 @@ const authzQueryKeys = { ...authzQueryKeys.teamMembersAll(scope), querySettings] as const, permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const, library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const, + allRoleAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'allRoleAssignments', querySettings] as const, orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const, scopes: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'scopes', search, page, pageSize] as const, }; @@ -114,6 +117,29 @@ export const useRevokeUserRoles = () => { }); }; +/** + * 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. + * + * @param querySettings - Optional parameters for filtering by roles, scopes, + * organizations, search term, sorting, and pagination. + * + * @example + * const { data: roleAssignments } = useAllRoleAssignments({ roles: 'editor', pageSize: 20 }); + */ +export const useAllRoleAssignments = (querySettings: QuerySettings) => { + const result = useQuery({ + queryKey: authzQueryKeys.allRoleAssignments(querySettings), + queryFn: () => getAllRoleAssignments(querySettings), + staleTime: 1000 * 60 * 30, // refetch after 30 minutes + retry: false, + refetchOnWindowFocus: false, + }); + return result; +}; + /** * React query hook to fetch the list of organizations for the organization filter component. * @param search - The search term to filter organizations. @@ -123,17 +149,20 @@ export const useOrgs = (search?: string, page?: number, pageSize?: number) => { const result = useQuery({ queryKey: authzQueryKeys.orgs(search, page, pageSize), queryFn: () => getOrgs(search, page, pageSize), - staleTime: 1000 * 60 * 30, // refetch after 30 minutes refetchOnWindowFocus: false, }); return result; }; +/* + * React query hook to fetch the list of scopes for the scope filter component. + * @param search - The search term to filter scopes. + * @returns The list of scopes matching the search term. + */ export const useScopes = (search?: string, page?: number, pageSize?: number) => { const result = useQuery({ queryKey: authzQueryKeys.scopes(search, page, pageSize), queryFn: () => getScopes(search, page, pageSize), - staleTime: 1000 * 60 * 30, // refetch after 30 minutes refetchOnWindowFocus: false, }); return result; diff --git a/src/authz-module/hooks/useQuerySettings.tsx b/src/authz-module/hooks/useQuerySettings.tsx index 505d96be..33675c82 100644 --- a/src/authz-module/hooks/useQuerySettings.tsx +++ b/src/authz-module/hooks/useQuerySettings.tsx @@ -44,8 +44,8 @@ export const useQuerySettings = ( setQuerySettings((prevSettings) => { // Extract filters const rolesFilter = tableFilters.filters?.find((filter) => filter.id === 'role')?.value?.join(',') ?? ''; - const searchFilter = tableFilters.filters?.find((filter) => filter.id === 'username')?.value ?? ''; - const organizationsFilter = tableFilters.filters?.find((filter) => filter.id === 'organization')?.value?.join(',') ?? ''; + const searchFilter = tableFilters.filters?.find((filter) => filter.id === 'name')?.value ?? ''; + const organizationsFilter = tableFilters.filters?.find((filter) => filter.id === 'org')?.value?.join(',') ?? ''; const scopesFilter = tableFilters.filters?.find((filter) => filter.id === 'scope')?.value?.join(',') ?? ''; // Extract pagination diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx index 0ed2346a..63aba40e 100644 --- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx @@ -3,7 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Button, useToggle } from '@openedx/paragon'; import { Plus } from '@openedx/paragon/icons'; -import { PutAssignTeamMembersRoleResponse } from 'authz-module/data/api'; +import { PutAssignTeamMembersRoleResponse } from '@src/authz-module/data/api'; import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks'; import { RoleOperationErrorStatus } from '@src/authz-module/constants'; import { AppToast, useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext'; diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.tsx index 3763bd8d..5b1bc893 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/index.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx @@ -12,6 +12,7 @@ import { useTeamMembers } from '@src/authz-module/data/hooks'; import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext'; import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants'; +import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; import { useQuerySettings } from './hooks/useQuerySettings'; import TableControlBar from './components/TableControlBar'; import messages from './messages'; @@ -19,8 +20,6 @@ import { ActionCell, EmailCell, NameCell, RolesCell, } from './components/Cells'; -const DEFAULT_PAGE_SIZE = 10; - const TeamTable = () => { const intl = useIntl(); const { @@ -39,7 +38,7 @@ const TeamTable = () => { } const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS); - const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / DEFAULT_PAGE_SIZE) : 1; + const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / TABLE_DEFAULT_PAGE_SIZE) : 1; const adaptedFilterChoices = useMemo( () => roles.map((role) => ({ @@ -68,7 +67,7 @@ const TeamTable = () => { data={rows} itemCount={teamMembers?.count || 0} pageCount={pageCount} - initialState={{ pageSize: DEFAULT_PAGE_SIZE }} + initialState={{ pageSize: TABLE_DEFAULT_PAGE_SIZE }} additionalColumns={[ { id: 'action', diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index 406fb7b1..fd15f47e 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -8,123 +8,19 @@ import { import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext'; import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; -import { TeamMember } from 'types'; import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter'; import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilter'; import ScopesFilter from '@src/authz-module/components/TableControlBar/ScopesFilter'; import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar'; -import { getCellHeader } from 'authz-module/components/utils'; +import { getCellHeader } from '@src/authz-module/components/utils'; import { - ActionCell, NameCell, RoleCell, ScopeCell, + ActionCell, 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 messages from './messages'; import TableFooter from '../components/TableFooter/TableFooter'; -const DEFAULT_PAGE_SIZE = 10; -// TODO: use the actual data from the API -const teamMembersMockedList: TeamMember[] = [ - { - username: 'admin', - fullName: 'Alice Johnson', - email: 'alice.johnson@example.edu', - createdAt: '2024-01-15T08:30:00Z', - scope: { resource: 'CS101', type: 'COURSE' }, - organization: 'MIT', - role: 'Course Admin', - roles: [], - }, - { - username: 'bob.smith', - fullName: 'Robert Smith', - email: 'bob.smith@university.org', - createdAt: '2024-02-10T14:22:00Z', - scope: { resource: 'math-library', type: 'LIBRARY' }, - organization: 'Stanford', - role: 'Library Author', - roles: [], - }, - { - username: 'carol.davis', - fullName: 'Carol Davis', - email: 'c.davis@adminpanel.edu', - createdAt: '2023-12-05T09:15:00Z', - scope: { resource: 'system', type: 'GLOBAL' }, - organization: 'Harvard', - role: 'Super Admin', - roles: [], - }, - { - username: 'david.wilson', - fullName: 'David Wilson', - email: 'david.w@teaching.com', - createdAt: '2024-03-01T16:45:00Z', - scope: { resource: 'PHYS201', type: 'COURSE' }, - organization: 'Caltech', - role: 'Course Staff', - roles: [], - }, - { - username: 'emma.brown', - fullName: 'Emma Brown', - email: 'emma.brown@lib.edu', - createdAt: '2024-01-28T11:30:00Z', - scope: { resource: 'science-resources', type: 'LIBRARY' }, - organization: 'Berkeley', - role: 'Library Admin', - roles: [], - }, - { - username: 'frank.miller', - fullName: 'Franklin Miller', - email: 'f.miller@global.org', - createdAt: '2023-11-20T13:00:00Z', - scope: { resource: 'platform', type: 'GLOBAL' }, - organization: 'Yale', - role: 'Global Staff', - roles: [], - }, - { - username: 'grace.lee', - fullName: 'Grace Lee', - email: 'grace.lee@courses.edu', - createdAt: '2024-02-14T10:15:00Z', - scope: { resource: 'HIST150', type: 'COURSE' }, - organization: 'Princeton', - role: 'Course Admin', - roles: [], - }, - { - username: 'henry.taylor', - fullName: 'Henry Taylor', - email: 'h.taylor@library.net', - createdAt: '2024-01-08T15:20:00Z', - scope: { resource: 'literature-collection', type: 'LIBRARY' }, - organization: 'Columbia', - role: 'Library Author', - roles: [], - }, - { - username: 'isabel.garcia', - fullName: 'Isabel Garcia', - email: 'i.garcia@admin.edu', - createdAt: '2023-10-12T07:45:00Z', - scope: { resource: 'system', type: 'GLOBAL' }, - organization: 'MIT', - role: 'Super Admin', - roles: [], - }, - { - username: 'jack.anderson', - fullName: 'Jack Anderson', - email: 'jack.a@support.com', - createdAt: '2024-02-25T12:10:00Z', - scope: { resource: 'CHEM301', type: 'COURSE' }, - organization: 'Northwestern', - role: 'Course Staff', - roles: [], - }, -]; - interface TeamMembersTableProps { presetScope?: string; } @@ -134,25 +30,36 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const { showErrorToast } = useToastManager(); const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState([]); - // TODO: add querySettings to the dependencies of useTeamMembers and handleTableFetch once the API integration is done - // const { querySettings, handleTableFetch } = useQuerySettings(); - const { handleTableFetch } = useQuerySettings(); + const initialQuerySettings = presetScope ? { + scopes: presetScope, + pageSize: TABLE_DEFAULT_PAGE_SIZE, + pageIndex: 0, + // Add other required QuerySettings properties with default values + roles: null, + organizations: null, + search: null, + order: null, + sortBy: null, + } : undefined; + + const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings); + + const { + data: { results: roleAssignments, count } = { results: [], count: 0 }, + isLoading: isLoadingAllRoleAssignments, + error, + refetch, + } = useAllRoleAssignments(querySettings); - // TODO: use the actual data from the API once the integration is done - /* const { - data: teamMembers, isError, error, refetch, - } = useTeamMembers(libraryId, querySettings); - */ const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; - const error = null; - const refetch = () => {}; - if (error) { - showErrorToast(error, refetch); - } + useEffect(() => { + if (error) { + showErrorToast(error, refetch); + } + }, [error, showErrorToast, refetch]); - // const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS); - const pageCount = teamMembersMockedList?.length ? Math.ceil(teamMembersMockedList.length / DEFAULT_PAGE_SIZE) : 1; + const pageCount = Math.ceil(count / TABLE_DEFAULT_PAGE_SIZE); const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]); @@ -169,10 +76,11 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { manualSortBy numBreakoutFilters={4} fetchData={fetchData} - data={teamMembersMockedList} - itemCount={teamMembersMockedList?.length || 0} + data={roleAssignments} + itemCount={count} pageCount={pageCount} - initialState={{ pageSize: DEFAULT_PAGE_SIZE }} + initialState={{ pageSize: TABLE_DEFAULT_PAGE_SIZE, filters: initialFilters }} + isLoading={isLoadingAllRoleAssignments} additionalColumns={[ { id: 'action', @@ -198,8 +106,9 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { Filter: TextFilter, }, { - Header: getCellHeader('organization', intl.formatMessage(messages['authz.team.members.table.column.organization.title']), columnsWithFiltersApplied), - accessor: 'organization', + Header: getCellHeader('org', intl.formatMessage(messages['authz.team.members.table.column.organization.title']), columnsWithFiltersApplied), + accessor: 'org', + Cell: OrgCell, filter: 'includesValue', Filter: OrgFilter, filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.organization.title']), @@ -226,7 +135,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { ] } > - + diff --git a/src/types.ts b/src/types.ts index ef67c453..1ce1c194 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,7 +12,6 @@ export interface TeamMember { username: string; fullName: string; email: string; - // TODO: remove when library team members get removed roles: string[]; createdAt: string; scope: { resource: string, type: 'COURSE' | 'LIBRARY' | 'GLOBAL' }; @@ -56,13 +55,13 @@ export type PermissionMetadata = { export type Org = { id: string; name: string; + shortName: string; }; export type Scope = { - key: string; - name: string; - description: string; - organization: Org; + externalKey: string; + displayName: string; + org: Org; }; // Permissions Matrix @@ -107,3 +106,14 @@ export type AppContextType = { email: string; }; }; + +export interface UserRole { + isSuperadmin?: boolean; + role: string; + org: string; + scope: string; + permissionCount: number; + fullName?: string; + username?: string; + email?: string; +} From d6ea3d9268feb52de3bacd9105b4f1cbb0e38a4e Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Wed, 15 Apr 2026 22:09:16 -0600 Subject: [PATCH 12/15] test: adding new ut for new team members table an its components --- src/authz-module/authz-home/index.test.tsx | 9 + src/authz-module/authz-home/index.tsx | 2 +- .../components/AddRoleButton.test.tsx | 134 ++++++ .../components/TableCells.test.tsx | 420 ++++++++++++++++++ src/authz-module/components/TableCells.tsx | 6 +- .../MultipleChoiceFilter.test.tsx | 164 +++++++ .../TableControlBar/OrgFilter.test.tsx | 63 +++ .../TableControlBar/RolesFilter.test.tsx | 38 ++ .../TableControlBar/ScopesFilter.test.tsx | 67 +++ .../TableControlBar/ScopesFilter.tsx | 4 +- .../TableControlBar/SearchFilter.test.tsx | 50 +++ .../TableControlBar/TableControlBar.test.tsx | 370 +++++++++++++++ .../TableFooter/TableFooter.test.tsx | 195 ++++++++ src/authz-module/components/messages.ts | 2 +- src/authz-module/components/utils.test.tsx | 84 ++++ src/authz-module/data/api.test.ts | 347 +++++++++++++++ src/authz-module/data/hooks.test.tsx | 187 ++++++++ .../hooks/useQuerySettings.test.tsx | 35 +- .../team-members/TeamMembersTable.test.tsx | 212 +++++++++ .../team-members/TeamMembersTable.tsx | 13 +- src/setupTest.tsx | 24 + 21 files changed, 2403 insertions(+), 23 deletions(-) create mode 100644 src/authz-module/components/AddRoleButton.test.tsx create mode 100644 src/authz-module/components/TableCells.test.tsx create mode 100644 src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx create mode 100644 src/authz-module/components/TableControlBar/OrgFilter.test.tsx create mode 100644 src/authz-module/components/TableControlBar/RolesFilter.test.tsx create mode 100644 src/authz-module/components/TableControlBar/ScopesFilter.test.tsx create mode 100644 src/authz-module/components/TableControlBar/SearchFilter.test.tsx create mode 100644 src/authz-module/components/TableControlBar/TableControlBar.test.tsx create mode 100644 src/authz-module/components/TableFooter/TableFooter.test.tsx create mode 100644 src/authz-module/components/utils.test.tsx create mode 100644 src/authz-module/data/api.test.ts create mode 100644 src/authz-module/team-members/TeamMembersTable.test.tsx diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index 43f3da1d..66414a10 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -16,6 +16,10 @@ jest.mock('@openedx/paragon', () => ({ Tabs: ({ children }: { children: React.ReactNode }) =>
{children}
, })); +jest.mock('@src/authz-module/team-members/TeamMembersTable', () => function MockTeamMembersTable() { + return
Team Members Table Content
; +}); + describe('AuthzHome', () => { it('renders without crashing', () => { renderWrapper(); @@ -37,4 +41,9 @@ describe('AuthzHome', () => { renderWrapper(); expect(screen.getByTestId('roles-permissions')).toBeInTheDocument(); }); + + it('renders the TeamMembersTable component in the team members tab', () => { + renderWrapper(); + expect(screen.getByText('Team Members Table Content')).toBeInTheDocument(); + }); }); diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx index 6537e28c..7f508d06 100644 --- a/src/authz-module/authz-home/index.tsx +++ b/src/authz-module/authz-home/index.tsx @@ -14,7 +14,7 @@ const AuthzHome = () => { const [searchParams] = useSearchParams(); const presetScope = searchParams.get('scope') || undefined; - const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || ''; + const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']); const pageTitle = intl.formatMessage(messages['authz.manage.page.title']); return ( diff --git a/src/authz-module/components/AddRoleButton.test.tsx b/src/authz-module/components/AddRoleButton.test.tsx new file mode 100644 index 00000000..7765faf4 --- /dev/null +++ b/src/authz-module/components/AddRoleButton.test.tsx @@ -0,0 +1,134 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useNavigate } from 'react-router-dom'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import AddRoleButton from './AddRoleButton'; + +// Mock react-router-dom navigation +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); + +describe('AddRoleButton', () => { + const mockNavigate = jest.fn(); + + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + beforeEach(() => { + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the assign role button with correct text', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button).toBeInTheDocument(); + }); + + it('displays the plus icon', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders correctly when presetUsername is provided', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + expect(button).toBeInTheDocument(); + }); + }); + + describe('navigation behavior', () => { + it('navigates to assign role page without username when clicked', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('navigates to assign role page with username query parameter when presetUsername is provided', async () => { + const user = userEvent.setup(); + const presetUsername = 'john.doe'; + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + }); + + it('handles special characters in presetUsername correctly', async () => { + const user = userEvent.setup(); + const presetUsername = 'user@example.com'; + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + }); + }); + + describe('user interactions', () => { + it('responds to keyboard navigation', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + + await user.tab(); + expect(button).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('responds to spacebar activation', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + button.focus(); + + await user.keyboard(' '); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role'); + }); + + it('handles multiple clicks gracefully', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: /assign role/i }); + + await user.click(button); + await user.click(button); + await user.click(button); + + expect(mockNavigate).toHaveBeenCalledTimes(3); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser'); + }); + }); +}); diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx new file mode 100644 index 00000000..00e7ad3d --- /dev/null +++ b/src/authz-module/components/TableCells.test.tsx @@ -0,0 +1,420 @@ +import { screen } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import userEvent from '@testing-library/user-event'; +import { + NameCell, + ViewActionCell, + RoleCell, + OrgCell, + ScopeCell, +} from './TableCells'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +describe('TableCells Components', () => { + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('NameCell', () => { + const mockUserRole = { + isSuperadmin: false, + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 27, + fullName: 'John Doe', + username: 'johndoe', + email: 'johndoe@example.com', + }; + const mockCellProps = { + row: { + original: mockUserRole, + }, + }; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'testuser@example.com', + }, + }); + }); + + it('displays the full name when available', () => { + renderWrapper(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('displays username when full name is not available', () => { + const propsWithoutFullName = { + row: { + original: { + ...mockUserRole, + fullName: undefined, + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + }); + + it('displays username when full name is empty string', () => { + const propsWithEmptyFullName = { + row: { + original: { + ...mockUserRole, + fullName: '', + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + }); + + it('shows current user indicator when username matches authenticated user', () => { + const currentUserProps = { + row: { + original: { + ...mockUserRole, + username: 'testuser', + fullName: 'Test User', + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('Test User')).toBeInTheDocument(); + expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); + }); + + it('does not show current user indicator when username does not match authenticated user', () => { + renderWrapper(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); + + it('shows current user indicator with username fallback when no full name is provided', () => { + const currentUserPropsNoFullName = { + row: { + original: { + ...mockUserRole, + username: 'testuser', + fullName: undefined, + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); + }); + + it('handles missing username in authenticated user gracefully', () => { + const contextWithoutUsername = { + authenticatedUser: { + username: undefined, + email: 'testuser@example.com', + }, + }; + + renderWrapper(, contextWithoutUsername); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); + }); + + describe('ViewActionCell', () => { + const mockUserRole = { + isSuperadmin: false, + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 27, + fullName: 'John Doe', + username: 'johndoe', + email: 'johndoe@example.com', + }; + + const mockCellProps = { + row: { + original: mockUserRole, + }, + }; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'testuser@example.com', + }, + }); + mockNavigate.mockClear(); + }); + + it('renders view action button', () => { + renderWrapper(); + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toBeInTheDocument(); + }); + + it('has correct accessibility attributes', () => { + renderWrapper(); + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toHaveAttribute('aria-label'); + }); + + it('navigates to user profile when clicked', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/johndoe'); + }); + + it('navigates with correct username for different user', async () => { + const user = userEvent.setup(); + const differentUserProps = { + row: { + original: { + ...mockUserRole, + username: 'janedoe', + }, + }, + }; + + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/janedoe'); + }); + + it('handles empty username gracefully', async () => { + const user = userEvent.setup(); + const emptyUsernameProps = { + row: { + original: { + ...mockUserRole, + username: '', + }, + }, + }; + + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/'); + }); + + it('handles special characters in username', async () => { + const user = userEvent.setup(); + const specialUsernameProps = { + row: { + original: { + ...mockUserRole, + username: 'user+with@special.chars', + }, + }, + }; + + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + await user.click(viewButton); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/user/user+with@special.chars'); + }); + }); + + describe('RoleCell', () => { + const mockCell = { + getCellProps: jest.fn(() => ({ 'data-testid': 'role-cell' })), + }; + + it('renders the role label for a mapped role', () => { + const props = { + value: 'library_admin', + cell: mockCell, + row: { + original: { + role: 'library_admin', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + expect(screen.getByText('Library Admin')).toBeInTheDocument(); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Library Admin' }); + }); + + it('renders empty string for unmapped role', () => { + const props = { + value: 'unknown_role', + cell: mockCell, + row: { + original: { + role: 'unknown_role', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + const cellElement = screen.getByTestId('role-cell'); + expect(cellElement).toHaveTextContent(''); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': '' }); + }); + + it('applies cell props correctly', () => { + const props = { + value: 'course_staff', + cell: mockCell, + row: { + original: { + role: 'course_staff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'role' }, + }; + + renderWrapper(); + + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Course Staff' }); + }); + }); + + describe('OrgCell', () => { + it('displays "All Organizations" for Django superuser role', () => { + const props = { + value: 'Test Org', + row: { + original: { + role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); + }); + + it('displays "All Organizations" for Django global staff role', () => { + const props = { + value: 'Test Org', + row: { + original: { + role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); + }); + + it('displays the actual org value for non-Django roles', () => { + const props = { + value: 'Test Organization', + row: { + original: { + role: 'library_admin', org: 'Test Organization', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'org' }, + }; + + renderWrapper(); + + expect(screen.getByText('Test Organization')).toBeInTheDocument(); + expect(screen.queryByText('All Organizations')).not.toBeInTheDocument(); + }); + }); + + describe('ScopeCell', () => { + it('displays "Global" for Django superuser role', () => { + const props = { + value: 'library', + row: { + original: { + role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.queryByText('library')).not.toBeInTheDocument(); + }); + + it('displays "Global" for Django global staff role', () => { + const props = { + value: 'course', + row: { + original: { + role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.queryByText('course')).not.toBeInTheDocument(); + }); + + it('displays the actual scope value for non-Django roles', () => { + const props = { + value: 'Course Scope', + row: { + original: { + role: 'course_admin', org: 'Test Org', scope: 'Course Scope', permissionCount: 1, + }, + }, + column: { id: 'scope' }, + }; + + renderWrapper(); + + expect(screen.getByText('Course Scope')).toBeInTheDocument(); + expect(screen.queryByText('Global')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 36fd9c4c..1fd89c18 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -37,7 +37,7 @@ const NameCell = ({ row }: CellProps) => { return row.original.fullName || row.original.username; }; -const ActionCell = ({ row }: CellProps) => { +const ViewActionCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); const navigate = useNavigate(); const viewPath = `/authz/user/${row.original.username}`; @@ -70,7 +70,7 @@ const ScopeCell = ({ row }: CellProps) => { iconSrc: RESOURCE_ICONS.GLOBAL, }; } - const scopeIcon = row.original.role.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + const scopeIcon = row.original.role?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; return { scopeText: row.original.scope, iconSrc: scopeIcon, @@ -92,5 +92,5 @@ const RoleCell = ({ value, cell }: ExtendedCellProps) => ( ); export { - NameCell, ActionCell, ScopeCell, RoleCell, OrgCell, + NameCell, ViewActionCell, ScopeCell, RoleCell, OrgCell, }; diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx new file mode 100644 index 00000000..5eac502f --- /dev/null +++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx @@ -0,0 +1,164 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import MultipleChoiceFilter from './MultipleChoiceFilter'; + +describe('MultipleChoiceFilter', () => { + const defaultProps = { + filterButtonText: 'Test Filter', + filterChoices: [ + { displayName: 'Option 1', value: 'option1' }, + { displayName: 'Option 2', value: 'option2' }, + ], + filterValue: [], + setFilter: jest.fn(), + }; + + const groupedChoices = [ + { displayName: 'Group A Option', value: 'groupA1', groupName: 'Group A' }, + { displayName: 'Group B Option', value: 'groupB1', groupName: 'Group B' }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('displays filter button text', () => { + renderWrapper(); + expect(screen.getByText('Custom Filter')).toBeInTheDocument(); + }); + + it('shows count when items are selected', () => { + renderWrapper(); + expect(screen.getByText('Test Filter (1)')).toBeInTheDocument(); + }); + + it('opens dropdown menu when clicked', async () => { + const user = userEvent.setup(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('displays search input when searchable', async () => { + const user = userEvent.setup(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('handles grouped choices', async () => { + const user = userEvent.setup(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + expect(screen.getByText('Group A')).toBeInTheDocument(); + expect(screen.getByText('Group B')).toBeInTheDocument(); + const CheckA1 = screen.getByText('Group A Option'); + await user.click(CheckA1); + expect(defaultProps.setFilter).toHaveBeenCalledWith( + ['groupA1'], + { + groupName: 'test filter', + value: 'groupA1', + displayName: 'Group A Option', + }, + ); + }); + + it('calls setFilter when option is selected', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + const option = screen.getByText('Option 1'); + await user.click(option); + expect(mockSetFilter).toHaveBeenCalled(); + }); + + it('adds option to selection on first click', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter'); + await user.click(button); + const checkbox = screen.getByLabelText('Option 1'); + await user.click(checkbox); + expect(mockSetFilter).toHaveBeenCalledWith( + ['option1'], + { + groupName: 'test filter', + value: 'option1', + displayName: 'Option 1', + }, + ); + }); + + it('removes option from selection when already selected', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter (1)'); + await user.click(button); + const checkbox = screen.getByLabelText('Option 1'); + await user.click(checkbox); + expect(mockSetFilter).toHaveBeenCalledWith( + [], + { + groupName: 'test filter', + value: 'option1', + displayName: 'Option 1', + }, + ); + }); + + it('handles multiple selections correctly', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const button = screen.getByText('Test Filter (1)'); + await user.click(button); + const checkbox = screen.getByLabelText('Option 2'); + await user.click(checkbox); + expect(mockSetFilter).toHaveBeenCalledWith( + ['option1', 'option2'], + { + groupName: 'test filter', + value: 'option2', + displayName: 'Option 2', + }, + ); + }); + + it('calls onSearchChange when search input changes', async () => { + const user = userEvent.setup(); + const mockOnSearchChange = jest.fn(); + renderWrapper( + , + ); + const button = screen.getByRole('button', { name: /test filter/i }); + await user.click(button); + const searchInput = screen.getByRole('textbox'); + await user.type(searchInput, 'test search'); + expect(mockOnSearchChange).toHaveBeenCalled(); + expect(mockOnSearchChange).toHaveBeenLastCalledWith('test search'); + }); +}); diff --git a/src/authz-module/components/TableControlBar/OrgFilter.test.tsx b/src/authz-module/components/TableControlBar/OrgFilter.test.tsx new file mode 100644 index 00000000..228b9d69 --- /dev/null +++ b/src/authz-module/components/TableControlBar/OrgFilter.test.tsx @@ -0,0 +1,63 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import OrgFilter from './OrgFilter'; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useOrgs: () => ({ + data: { + count: 2, + next: null, + previous: null, + results: [ + { id: 'org1', name: 'Organization 1' }, + { id: 'org2', name: 'Organization 2' }, + ], + }, + }), +})); + +describe('OrgFilter', () => { + const defaultProps = { + filterButtonText: 'Organizations', + filterValue: [], + setFilter: jest.fn(), + disabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('displays filter options', () => { + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); + + it('handles search input', async () => { + const user = userEvent.setup(); + renderWrapper(); + // Look for search input if it exists + const searchInputs = screen.queryAllByRole('textbox'); + if (searchInputs.length > 0) { + await user.type(searchInputs[0], 'test search'); + expect(searchInputs[0]).toHaveValue('test search'); + } + }); + + it('calls setFilter when filter changes', () => { + const mockSetFilter = jest.fn(); + renderWrapper(); + expect(screen.getByText('Organizations')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx new file mode 100644 index 00000000..1ba0724f --- /dev/null +++ b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import RolesFilter from './RolesFilter'; + +describe('RolesFilter', () => { + const defaultProps = { + filterButtonText: 'Roles', + filterValue: [], + setFilter: jest.fn(), + disabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Roles')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Roles')).toBeInTheDocument(); + }); + + it('displays filter button text', () => { + renderWrapper(); + expect(screen.getByText('Select Roles')).toBeInTheDocument(); + }); + + it('calls setFilter when filter changes', () => { + const mockSetFilter = jest.fn(); + renderWrapper(); + expect(screen.getByText('Roles')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx new file mode 100644 index 00000000..18654ad4 --- /dev/null +++ b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx @@ -0,0 +1,67 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import ScopesFilter from './ScopesFilter'; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useScopes: () => ({ + data: { + results: [ + { + externalKey: 'course:123', + name: 'Test Course', + organization: { name: 'Test Org' }, + }, + { + externalKey: 'library:456', + name: 'Test Library', + organization: { name: 'Another Org' }, + }, + ], + }, + }), +})); + +describe('ScopesFilter', () => { + const defaultProps = { + filterButtonText: 'Scopes', + filterValue: [], + setFilter: jest.fn(), + disabled: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByText('Scopes')).toBeInTheDocument(); + }); + + it('handles disabled state', () => { + renderWrapper(); + expect(screen.getByText('Scopes')).toBeInTheDocument(); + }); + + it('displays filter button text', () => { + renderWrapper(); + expect(screen.getByText('Select Scopes')).toBeInTheDocument(); + }); + + it('handles search input', async () => { + const user = userEvent.setup(); + renderWrapper(); + const searchInputs = screen.queryAllByRole('textbox'); + if (searchInputs.length > 0) { + await user.type(searchInputs[0], 'test search'); + expect(searchInputs[0]).toHaveValue('test search'); + } + }); + + it('calls setFilter when filter changes', () => { + const mockSetFilter = jest.fn(); + renderWrapper(); + expect(screen.getByText('Scopes')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx index 9820128a..4453a724 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx @@ -17,9 +17,9 @@ const ScopesFilter = ({ const { data: scopesData = { results: [] } } = useScopes(searchValue); const filterChoices = useMemo(() => scopesData.results.map((scope) => { - const scopeIcon = scope.externalKey.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; let groupName = formatMessage(messages['authz.team.members.table.group.courses']); - if (scope.externalKey.startsWith('lib')) { + if (scope.externalKey?.startsWith('lib')) { groupName = formatMessage(messages['authz.team.members.table.group.libraries']); } return { diff --git a/src/authz-module/components/TableControlBar/SearchFilter.test.tsx b/src/authz-module/components/TableControlBar/SearchFilter.test.tsx new file mode 100644 index 00000000..f66a7a8e --- /dev/null +++ b/src/authz-module/components/TableControlBar/SearchFilter.test.tsx @@ -0,0 +1,50 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import SearchFilter from './SearchFilter'; + +describe('SearchFilter', () => { + const defaultProps = { + filterValue: '', + setFilter: jest.fn(), + placeholder: 'Search...', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + renderWrapper(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('displays placeholder text', () => { + renderWrapper(); + expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument(); + }); + + it('displays current filter value', () => { + renderWrapper(); + expect(screen.getByDisplayValue('test query')).toBeInTheDocument(); + }); + + it('calls setFilter when input changes', async () => { + const user = userEvent.setup(); + const mockSetFilter = jest.fn(); + renderWrapper(); + const input = screen.getByRole('textbox'); + await user.type(input, 'search text'); + expect(mockSetFilter).toHaveBeenCalled(); + }); + + it('handles empty filter value', () => { + renderWrapper(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('handles undefined filter value', () => { + renderWrapper(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); +}); diff --git a/src/authz-module/components/TableControlBar/TableControlBar.test.tsx b/src/authz-module/components/TableControlBar/TableControlBar.test.tsx new file mode 100644 index 00000000..a706e15e --- /dev/null +++ b/src/authz-module/components/TableControlBar/TableControlBar.test.tsx @@ -0,0 +1,370 @@ +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import { DataTableContext, TextFilter } from '@openedx/paragon'; +import userEvent from '@testing-library/user-event'; +import TableControlBar from './TableControlBar'; +import RolesFilter from './RolesFilter'; + +const mockSetAllFilters = jest.fn(); +const mockOnFilterChange = jest.fn(); + +const mockColumns = [ + { + id: 'role', + canFilter: true, + Filter: () => null, + setFilter: jest.fn(), + filterOrder: 1, + }, + { + id: 'org', + canFilter: true, + Filter: () => null, + setFilter: jest.fn(), + filterOrder: 2, + }, +]; + +const mockState = { + filters: [ + { id: 'role', value: ['admin'] }, + { id: 'org', value: ['org1'] }, + ], +}; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useOrgs: () => ({ + data: { + count: 0, next: null, previous: null, results: [], + }, + }), + useScopes: () => ({ data: { scopes: [] } }), +})); + +describe('TableControlBar', () => { + const mockDataTableContext = { + columns: mockColumns, + setAllFilters: mockSetAllFilters, + state: mockState, + }; + + const renderWithContext = (component, contextOverride = {}) => { + const context = { ...mockDataTableContext, ...contextOverride }; + return renderWrapper( + + {component} + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSetAllFilters.mockClear(); + mockOnFilterChange.mockClear(); + }); + + it('renders without crashing', () => { + renderWithContext(); + const container = document.querySelector('.authz-table-control-bar'); + expect(container).toBeInTheDocument(); + }); + + it('renders roles filter when configured', async () => { + const user = userEvent.setup(); + const contextWithRolesFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithRolesFilter); + const rolesButton = screen.getByText('Select Roles'); + expect(rolesButton).toBeInTheDocument(); + await user.click(rolesButton); + const superAdminOption = screen.getByRole('checkbox', { name: /Super Admin/i }); + expect(superAdminOption).toBeInTheDocument(); + await user.click(superAdminOption); + expect(contextWithRolesFilter.columns[0].setFilter).toHaveBeenCalled(); + }); + + it('renders search filter when configured', () => { + const contextWithTextFilter = { + columns: [ + { + id: 'search', + Header: 'Search Field', + Filter: TextFilter, + canFilter: true, + filterValue: '', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithTextFilter); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('displays filter chips when filters are applied', () => { + const contextWithAppliedFilters = { + state: { + filters: [ + { id: 'roles', value: ['Admin'] }, + ], + }, + }; + + renderWithContext( + , + contextWithAppliedFilters, + ); + + expect(screen.getByText('Filtered by:')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('shows clear all button when multiple filters applied', async () => { + const user = userEvent.setup(); + const contextWithMultipleFilters = { + state: { + filters: [ + { id: 'roles', value: ['Admin'] }, + { id: 'org', value: ['TestOrg'] }, + ], + }, + }; + + renderWithContext( + , + contextWithMultipleFilters, + ); + const clearButton = screen.getByText('Clear filters'); + expect(clearButton).toBeInTheDocument(); + await user.click(clearButton); + expect(mockSetAllFilters).toHaveBeenCalledWith([]); + expect(screen.queryByText('Clear filters')).not.toBeInTheDocument(); + }); + + it('calls onFilterChange callback when provided', () => { + const contextWithFilters = { + state: { + filters: [{ id: 'test', value: ['value'] }], + }, + }; + + renderWithContext( + , + contextWithFilters, + ); + + expect(mockOnFilterChange).toHaveBeenCalledWith(['test']); + }); + + it('handles empty columns gracefully', () => { + renderWithContext(); + const container = document.querySelector('.authz-table-control-bar'); + expect(container).toBeInTheDocument(); + expect(screen.queryByText('Filter by')).not.toBeInTheDocument(); + }); + it('handles empty columns gracefully', () => { + renderWithContext(); + const container = document.querySelector('.authz-table-control-bar'); + expect(container).toBeInTheDocument(); + expect(screen.queryByText('Filter by')).not.toBeInTheDocument(); + }); + + it('generates keys using column id when available', () => { + const contextWithIdColumn = { + columns: [ + { + id: 'test-id', + accessor: 'test-accessor', + Header: 'Test', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Test Filter', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithIdColumn); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('generates keys using column accessor when id is not available', () => { + const contextWithAccessorColumn = { + columns: [ + { + accessor: 'test-accessor', + Header: 'Test', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Test Filter', + setFilter: jest.fn(), + }, + ], + }; + + renderWithContext(, contextWithAccessorColumn); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + }); + + it('handles chronological filter logic for adding new filters', () => { + const mockSetFilter = jest.fn(); + const contextWithFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: mockSetFilter, + }, + ], + }; + + renderWithContext(, contextWithFilter); + + const handleSetFilters = mockSetFilter.mock.calls[0]?.[0]; + if (handleSetFilters) { + const newFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' }; + handleSetFilters(['admin'], newFilter); + } + }); + + it('handles chronological filter logic for removing existing filters', () => { + const mockSetFilter = jest.fn(); + const contextWithFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: mockSetFilter, + }, + ], + }; + + renderWithContext( + , + contextWithFilter, + ); + + const handleSetFilters = mockSetFilter.mock.calls[0]?.[0]; + if (handleSetFilters) { + const existingFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' }; + handleSetFilters([], existingFilter); + } + }); + + it('tests onIconAfterClick functionality directly', () => { + const contextWithAppliedFilters = { + setAllFilters: mockSetAllFilters, + state: { + filters: [ + { id: 'role', value: ['admin', 'user'] }, + { id: 'org', value: ['TestOrg'] }, + ], + }, + }; + + renderWithContext( + , + contextWithAppliedFilters, + ); + const chipElement = screen.getByText('admin').closest('.pgn__chip'); + expect(chipElement).toBeInTheDocument(); + const closeButton = chipElement?.querySelector('button'); + if (closeButton) { + closeButton.click(); + } + expect(mockSetAllFilters).toHaveBeenCalledWith([ + { id: 'role', value: ['user'] }, + { id: 'org', value: ['TestOrg'] }, + ]); + }); + + it('displays warning alert when filter limit is reached', () => { + const maxFilters = Array.from({ length: 10 }, (_, index) => ({ + id: `filter${index}`, + value: [`value${index}`], + })); + + const contextWithMaxFilters = { + setAllFilters: mockSetAllFilters, + state: { + filters: maxFilters, + }, + }; + + renderWithContext( + , + contextWithMaxFilters, + ); + const warningAlert = screen.getByRole('alert'); + expect(warningAlert).toBeInTheDocument(); + expect(warningAlert).toHaveClass('alert-warning'); + }); + + it('manages setChronologicalFilters state correctly when removing filters', () => { + const mockSetFilter = jest.fn(); + const contextWithFilter = { + columns: [ + { + id: 'roles', + Header: 'Roles', + Filter: RolesFilter, + canFilter: true, + filterButtonText: 'Select Roles', + setFilter: mockSetFilter, + }, + ], + }; + + renderWithContext( + , + contextWithFilter, + ); + const handleSetFilters = mockSetFilter.mock.calls[0]?.[0]; + if (handleSetFilters) { + const existingFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' }; + handleSetFilters([], existingFilter); + expect(mockSetFilter).toHaveBeenCalled(); + } + }); + + it('removes a filter chip when close icon is clicked', async () => { + renderWithContext(); + const chip = screen.getByText('admin'); + const closeButton = chip.parentElement?.querySelector('button'); + const user = userEvent.setup(); + await user.click(closeButton as HTMLElement); + expect(mockSetAllFilters).toHaveBeenCalledWith([ + { id: 'role', value: [] }, + { id: 'org', value: ['org1'] }, + ]); + }); + + it('removes the correct filter chip for org', async () => { + renderWithContext(); + const chip = screen.getByText('org1'); + const closeButton = chip.parentElement?.querySelector('button'); + const user = userEvent.setup(); + await user.click(closeButton as HTMLElement); + expect(mockSetAllFilters).toHaveBeenCalledWith([ + { id: 'role', value: ['admin'] }, + { id: 'org', value: [] }, + ]); + }); +}); diff --git a/src/authz-module/components/TableFooter/TableFooter.test.tsx b/src/authz-module/components/TableFooter/TableFooter.test.tsx new file mode 100644 index 00000000..b03f4efe --- /dev/null +++ b/src/authz-module/components/TableFooter/TableFooter.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DataTableContext } from '@openedx/paragon'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import Footer from './TableFooter'; + +describe('TableFooter', () => { + const mockGotoPage = jest.fn(); + + const defaultDataTableContext = { + pageCount: 5, + gotoPage: mockGotoPage, + state: { + pageIndex: 0, + pageSize: 10, + }, + itemCount: 42, + rows: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' }, + ], + }; + + const renderFooter = (contextOverrides = {}) => { + const contextValue = { + ...defaultDataTableContext, + ...contextOverrides, + }; + + return renderWrapper( + +