From 9a3be807453ef02f71771203e1b151680f0e9f99 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 19 Feb 2026 23:34:11 -0600 Subject: [PATCH 1/2] feat: main content course team --- src/courseTeam/CourseTeamPage.test.tsx | 60 ++++++++ src/courseTeam/CourseTeamPage.tsx | 25 ++- .../components/MembersContent.test.tsx | 128 ++++++++++++++++ src/courseTeam/components/MembersContent.tsx | 73 +++++++++ src/courseTeam/components/RolesContent.tsx | 9 ++ src/courseTeam/data/api.test.ts | 78 ++++++++++ src/courseTeam/data/api.ts | 34 +++++ src/courseTeam/data/apiHook.test.tsx | 142 ++++++++++++++++++ src/courseTeam/data/apiHook.ts | 20 +++ src/courseTeam/data/queryKeys.ts | 18 +++ src/courseTeam/messages.ts | 56 +++++++ src/courseTeam/types.ts | 12 ++ 12 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 src/courseTeam/CourseTeamPage.test.tsx create mode 100644 src/courseTeam/components/MembersContent.test.tsx create mode 100644 src/courseTeam/components/MembersContent.tsx create mode 100644 src/courseTeam/components/RolesContent.tsx create mode 100644 src/courseTeam/data/api.test.ts create mode 100644 src/courseTeam/data/api.ts create mode 100644 src/courseTeam/data/apiHook.test.tsx create mode 100644 src/courseTeam/data/apiHook.ts create mode 100644 src/courseTeam/data/queryKeys.ts create mode 100644 src/courseTeam/messages.ts create mode 100644 src/courseTeam/types.ts diff --git a/src/courseTeam/CourseTeamPage.test.tsx b/src/courseTeam/CourseTeamPage.test.tsx new file mode 100644 index 00000000..4817ee9f --- /dev/null +++ b/src/courseTeam/CourseTeamPage.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithIntl } from '@src/testUtils'; +import CourseTeamPage from './CourseTeamPage'; + +// Mock the child components, each component should have its own test suite +jest.mock('./components/MembersContent', () => { + return function MembersContent() { + return
Members Content
; + }; +}); + +jest.mock('./components/RolesContent', () => { + return function RolesContent() { + return
Roles Content
; + }; +}); + +describe('CourseTeamPage', () => { + it('renders the course team title', () => { + renderWithIntl(); + expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument(); + }); + + it('renders the add team member button', () => { + renderWithIntl(); + expect(screen.getByRole('button', { name: /add team member/i })).toBeInTheDocument(); + }); + + it('renders both tabs', () => { + renderWithIntl(); + expect(screen.getByRole('tab', { name: /members/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /roles/i })).toBeInTheDocument(); + }); + + it('renders MembersContent by default', () => { + renderWithIntl(); + expect(screen.getByText('Members Content')).toBeInTheDocument(); + }); + + it('has correct CSS classes on title', () => { + renderWithIntl(); + const title = screen.getByRole('heading', { level: 3 }); + expect(title).toHaveClass('text-primary-700', 'mb-0'); + }); + + it('has primary variant on add button', () => { + renderWithIntl(); + const button = screen.getByRole('button', { name: /add team member/i }); + expect(button).toHaveClass('btn-primary'); + }); + + it('renders RolesContent when Roles tab is selected', async () => { + renderWithIntl(); + const rolesTab = screen.getByRole('tab', { name: /roles/i }); + const user = userEvent.setup(); + await user.click(rolesTab); + expect(screen.getByText('Roles Content')).toBeInTheDocument(); + }); +}); diff --git a/src/courseTeam/CourseTeamPage.tsx b/src/courseTeam/CourseTeamPage.tsx index 065f294c..7449abf1 100644 --- a/src/courseTeam/CourseTeamPage.tsx +++ b/src/courseTeam/CourseTeamPage.tsx @@ -1,8 +1,27 @@ +import { useIntl } from '@openedx/frontend-base'; +import messages from './messages'; +import { Button, Tab, Tabs } from '@openedx/paragon'; +import MembersContent from './components/MembersContent'; +import RolesContent from './components/RolesContent'; + const CourseTeamPage = () => { + const intl = useIntl(); + return ( -
-

Course Team

-
+ <> +
+

{intl.formatMessage(messages.courseTeamTitle)}

+ +
+ + + + + + + + + ); }; diff --git a/src/courseTeam/components/MembersContent.test.tsx b/src/courseTeam/components/MembersContent.test.tsx new file mode 100644 index 00000000..978920d6 --- /dev/null +++ b/src/courseTeam/components/MembersContent.test.tsx @@ -0,0 +1,128 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithIntl } from '@src/testUtils'; +import { useTeamMembers } from '../data/apiHook'; +import MembersContent from './MembersContent'; +import messages from '../messages'; + +const courseId = 'course-v1:edX+DemoX+Demo_Course'; + +jest.mock('../data/apiHook', () => ({ + useTeamMembers: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ courseId: courseId }), +})); + +const mockTeamMembers = [ + { username: 'user1', email: 'user1@example.com', role: 'Admin' }, + { username: 'user2', email: 'user2@example.com', role: 'Staff' }, +]; + +const renderComponent = () => renderWithIntl(); + +describe('MembersContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders loading state correctly', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: [], numPages: 1, count: 0 }, + isLoading: true, + }); + + renderComponent(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('renders team members data correctly', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: mockTeamMembers, numPages: 1, count: 2 }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText(mockTeamMembers[0].username)).toBeInTheDocument(); + expect(screen.getByText(mockTeamMembers[0].email)).toBeInTheDocument(); + expect(screen.getByText(mockTeamMembers[0].role)).toBeInTheDocument(); + expect(screen.getByText(mockTeamMembers[1].username)).toBeInTheDocument(); + expect(screen.getByText(mockTeamMembers[1].email)).toBeInTheDocument(); + expect(screen.getByText(mockTeamMembers[1].role)).toBeInTheDocument(); + }); + + it('renders empty state when no team members', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: [], numPages: 1, count: 0 }, + isLoading: false, + }); + + renderComponent(); + expect(screen.getByText(messages.noTeamMembers.defaultMessage)).toBeInTheDocument(); + }); + + it('calls useTeamMembers with correct parameters', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: [], numPages: 1, count: 0 }, + isLoading: false, + }); + + renderComponent(); + + expect(useTeamMembers).toHaveBeenCalledWith(courseId, { + page: 0, + emailOrUsername: '', + role: '', + pageSize: 25, + }); + }); + + it('handles pagination correctly', async () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: mockTeamMembers, numPages: 3, count: 50 }, + isLoading: false, + }); + + renderComponent(); + + const nextPageButton = screen.getByLabelText(/next/i); + const user = userEvent.setup(); + await user.click(nextPageButton); + + expect(useTeamMembers).toHaveBeenLastCalledWith(courseId, { + page: 1, + emailOrUsername: '', + role: '', + pageSize: 25, + }); + }); + + it('renders action buttons for each row', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: mockTeamMembers, numPages: 1, count: 2 }, + isLoading: false, + }); + + renderComponent(); + + const editButtons = screen.getAllByText(messages.edit.defaultMessage); + expect(editButtons).toHaveLength(2); + }); + + it('renders table headers correctly', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: mockTeamMembers, numPages: 1, count: 2 }, + isLoading: false, + }); + + renderComponent(); + + expect(screen.getByText(messages.username.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.email.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.role.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.actions.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/courseTeam/components/MembersContent.tsx b/src/courseTeam/components/MembersContent.tsx new file mode 100644 index 00000000..0e8e3b5e --- /dev/null +++ b/src/courseTeam/components/MembersContent.tsx @@ -0,0 +1,73 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { Button, DataTable } from '@openedx/paragon'; +import messages from '../messages'; +import { useTeamMembers } from '../data/apiHook'; + +const TEAM_MEMBERS_PAGE_SIZE = 25; + +const MembersContent = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [filters, setFilters] = useState({ page: 0, emailOrUsername: '', role: '' }); + const { data: { results: teamMembers = [], numPages = 1, count = 0 } = {}, isLoading = false } = useTeamMembers(courseId, { ...filters, pageSize: TEAM_MEMBERS_PAGE_SIZE }); + + const tableColumns = useMemo(() => [ + { accessor: 'username', Header: intl.formatMessage(messages.username) }, + { accessor: 'email', Header: intl.formatMessage(messages.email) }, + { accessor: 'role', Header: intl.formatMessage(messages.role) }, + ], [intl]); + + const additionalColumns = useMemo(() => [{ + id: 'actions', + Header: intl.formatMessage(messages.actions), + Cell: () => ( + + ) + }], [intl]); + + const handleFetchData = useCallback(({ pageIndex, filters: tableFilters }: { pageIndex: number, filters: { id: string, value: string }[] }) => { + // Filters will be handled in a future iteration, for now we will just update pagination + console.log(pageIndex, tableFilters); + if (pageIndex !== filters.page) { + setFilters(prevFilters => ({ + ...prevFilters, + page: pageIndex, + })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const tableState = useMemo(() => ({ + pageIndex: filters.page, + pageSize: TEAM_MEMBERS_PAGE_SIZE, + }), [filters.page]); + + return ( + null} + > + + + + + + ); +}; + +export default MembersContent; diff --git a/src/courseTeam/components/RolesContent.tsx b/src/courseTeam/components/RolesContent.tsx new file mode 100644 index 00000000..0d46b418 --- /dev/null +++ b/src/courseTeam/components/RolesContent.tsx @@ -0,0 +1,9 @@ +const RolesContent = () => { + return ( +
+ Roles content goes here. +
+ ); +}; + +export default RolesContent; diff --git a/src/courseTeam/data/api.test.ts b/src/courseTeam/data/api.test.ts new file mode 100644 index 00000000..a1ee663a --- /dev/null +++ b/src/courseTeam/data/api.test.ts @@ -0,0 +1,78 @@ +import { getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getTeamMembers, getRoles } from './api'; + +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('../../data/api', () => ({ + getApiBaseUrl: jest.fn().mockReturnValue(''), +})); + +const httpClientMock = { + get: jest.fn(), +}; + +beforeEach(() => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue(httpClientMock); +}); + +describe('courseTeam API', () => { + describe('getTeamMembers', () => { + it('should call the correct endpoint to get team members', async () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const params = { page: 0, pageSize: 10 }; + httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } }); + + await getTeamMembers(courseId, params); + + const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10`; + expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl); + }); + + it('should include email_or_username in query params if provided', async () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const params = { page: 0, pageSize: 10, emailOrUsername: 'test@example.com' }; + httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } }); + + await getTeamMembers(courseId, params); + + const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10&email_or_username=test%40example.com`; + expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl); + }); + + it('should include role in query params if provided', async () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const params = { page: 0, pageSize: 10, role: 'instructor' }; + httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } }); + + await getTeamMembers(courseId, params); + + const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10&role=instructor`; + expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + + describe('getRoles', () => { + it('should call the correct endpoint to get roles', async () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + httpClientMock.get.mockResolvedValue({ data: { roles: [] } }); + + await getRoles(courseId); + + const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_roles`; + expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl); + }); + + it('should return the roles from the response', async () => { + const courseId = 'course-v1:edX+DemoX+Demo_Course'; + const roles = ['instructor', 'staff']; + httpClientMock.get.mockResolvedValue({ data: { roles } }); + + const result = await getRoles(courseId); + + expect(result).toEqual(roles); + }); + }); +}); diff --git a/src/courseTeam/data/api.ts b/src/courseTeam/data/api.ts new file mode 100644 index 00000000..f813a043 --- /dev/null +++ b/src/courseTeam/data/api.ts @@ -0,0 +1,34 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { DataList } from '@src/types'; +import { CourseTeamMember, CourseTeamMemberQueryParams } from '../types'; + +export const getTeamMembers = async ( + courseId: string, + params: CourseTeamMemberQueryParams +): Promise> => { + const queryParams = new URLSearchParams({ + page: (params.page + 1).toString(), + page_size: params.pageSize.toString(), + }); + + if (params.emailOrUsername) { + queryParams.append('email_or_username', params.emailOrUsername); + } + + if (params.role) { + queryParams.append('role', params.role); + } + + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team_members?${queryParams.toString()}` + ); + return camelCaseObject(data); +}; + +export const getRoles = async (courseId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team_roles` + ); + return data.roles; +}; diff --git a/src/courseTeam/data/apiHook.test.tsx b/src/courseTeam/data/apiHook.test.tsx new file mode 100644 index 00000000..642dad9d --- /dev/null +++ b/src/courseTeam/data/apiHook.test.tsx @@ -0,0 +1,142 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode } from 'react'; +import { useTeamMembers, useRoles } from './apiHook'; +import * as api from './api'; +import { CourseTeamMember } from '../types'; +import { DataList } from '../../types'; + +jest.mock('./api'); + +const mockGetTeamMembers = api.getTeamMembers as jest.MockedFunction; +const mockGetRoles = api.getRoles as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const WrappedComponent = ({ children }: { children: ReactNode }) => ( + {children} + ); + return WrappedComponent; +}; + +describe('apiHook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useTeamMembers', () => { + it('should fetch course team members successfully', async () => { + const mockTeamMembers: DataList = { + count: 2, + next: null, + previous: null, + numPages: 1, + results: [ + { username: 'john.doe', email: 'john@example.com', role: 'instructor' }, + { username: 'jane.smith', email: 'jane@example.com', role: 'staff' }, + ], + }; + + mockGetTeamMembers.mockResolvedValue(mockTeamMembers); + + const { result } = renderHook(() => useTeamMembers('course-v1:org+course+run', { + page: 0, + pageSize: 25, + }), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockTeamMembers); + expect(mockGetTeamMembers).toHaveBeenCalledWith('course-v1:org+course+run', { + page: 0, + pageSize: 25, + }); + }); + + it('should handle error when fetching course team fails', async () => { + const mockError = new Error('Failed to fetch course team'); + mockGetTeamMembers.mockRejectedValue(mockError); + + const { result } = renderHook(() => useTeamMembers('course-v1:org+course+run', { + page: 0, + pageSize: 25, + }), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(mockError); + }); + + it('should be disabled when courseId is empty', () => { + const { result } = renderHook(() => useTeamMembers('', { + page: 0, + pageSize: 25, + }), { + wrapper: createWrapper(), + }); + + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(mockGetTeamMembers).not.toHaveBeenCalled(); + }); + }); + + describe('useRoles', () => { + it('should fetch course roles successfully', async () => { + const mockRoles = ['instructor', 'staff', 'beta_testers']; + + mockGetRoles.mockResolvedValue(mockRoles); + + const { result } = renderHook(() => useRoles('course-v1:org+course+run'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockRoles); + expect(mockGetRoles).toHaveBeenCalledWith('course-v1:org+course+run'); + }); + + it('should handle error when fetching roles fails', async () => { + const mockError = new Error('Failed to fetch roles'); + mockGetRoles.mockRejectedValue(mockError); + + const { result } = renderHook(() => useRoles('course-v1:org+course+run'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(mockError); + }); + + it('should be disabled when courseId is empty', () => { + const { result } = renderHook(() => useRoles(''), { + wrapper: createWrapper(), + }); + + expect(result.current.isPending).toBe(true); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(mockGetRoles).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/courseTeam/data/apiHook.ts b/src/courseTeam/data/apiHook.ts new file mode 100644 index 00000000..bd7c925c --- /dev/null +++ b/src/courseTeam/data/apiHook.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { getRoles, getTeamMembers } from './api'; +import { CourseTeamMemberQueryParams } from '../types'; +import { courseTeamQueryKeys } from './queryKeys'; + +export const useTeamMembers = (courseId: string, params: CourseTeamMemberQueryParams) => ( + useQuery({ + queryKey: courseTeamQueryKeys.byCoursePaginated(courseId, params), + queryFn: () => getTeamMembers(courseId, params), + enabled: !!courseId, + }) +); + +export const useRoles = (courseId: string) => ( + useQuery({ + queryKey: courseTeamQueryKeys.roles(courseId), + queryFn: () => getRoles(courseId), + enabled: !!courseId, + }) +); diff --git a/src/courseTeam/data/queryKeys.ts b/src/courseTeam/data/queryKeys.ts new file mode 100644 index 00000000..dd3cd5e7 --- /dev/null +++ b/src/courseTeam/data/queryKeys.ts @@ -0,0 +1,18 @@ +import { appId } from '../../constants'; +import { CourseTeamMemberQueryParams } from '../types'; + +export const courseTeamQueryKeys = { + all: [appId, 'courseTeam'] as const, + byCourse: (courseId: string) => [...courseTeamQueryKeys.all, courseId] as const, + byCoursePaginated: ( + courseId: string, + params: CourseTeamMemberQueryParams + ) => [ + ...courseTeamQueryKeys.byCourse(courseId), + params.page, + params.pageSize, + params.emailOrUsername || '', + params.role || '' + ] as const, + roles: (courseId: string) => [...courseTeamQueryKeys.byCourse(courseId), 'roles'] as const, +}; diff --git a/src/courseTeam/messages.ts b/src/courseTeam/messages.ts new file mode 100644 index 00000000..2f7365a9 --- /dev/null +++ b/src/courseTeam/messages.ts @@ -0,0 +1,56 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + courseTeamTitle: { + id: 'instruct.courseTeam.page.title', + defaultMessage: 'Course Team Management', + description: 'Title for the course team page', + }, + addTeamMember: { + id: 'instruct.courseTeam.addTeamMember', + defaultMessage: 'Add Team Member', + description: 'Button label for adding a team member', + }, + membersTab: { + id: 'instruct.courseTeam.membersTab', + defaultMessage: 'Members', + description: 'Tab title for course team members', + }, + rolesTab: { + id: 'instruct.courseTeam.rolesTab', + defaultMessage: 'Roles', + description: 'Tab title for course team roles', + }, + username: { + id: 'instruct.courseTeam.username', + defaultMessage: 'Username', + description: 'Column header for team member username', + }, + email: { + id: 'instruct.courseTeam.email', + defaultMessage: 'Email', + description: 'Column header for team member email', + }, + role: { + id: 'instruct.courseTeam.role', + defaultMessage: 'Role', + description: 'Column header for team member role', + }, + actions: { + id: 'instruct.courseTeam.actions', + defaultMessage: 'Actions', + description: 'Column header for team member actions', + }, + edit: { + id: 'instruct.courseTeam.edit', + defaultMessage: 'Edit', + description: 'Button label for editing a team member', + }, + noTeamMembers: { + id: 'instruct.courseTeam.noTeamMembers', + defaultMessage: 'No team members found.', + description: 'Message displayed when there are no team members', + }, +}); + +export default messages; diff --git a/src/courseTeam/types.ts b/src/courseTeam/types.ts new file mode 100644 index 00000000..e016469e --- /dev/null +++ b/src/courseTeam/types.ts @@ -0,0 +1,12 @@ +export interface CourseTeamMember { + username: string, + email: string, + role: string, +}; + +export interface CourseTeamMemberQueryParams { + page: number, + pageSize: number, + emailOrUsername?: string, + role?: string, +}; From b12127d640196813b717f53f51338ff325746162 Mon Sep 17 00:00:00 2001 From: diana-villalvazo-wgu Date: Thu, 19 Feb 2026 23:34:46 -0600 Subject: [PATCH 2/2] feat: adding roles content --- .../components/RolesContent.test.tsx | 45 ++++++++ src/courseTeam/components/RolesContent.tsx | 39 ++++++- src/courseTeam/data/api.ts | 4 +- src/courseTeam/data/apiHook.test.tsx | 2 +- src/courseTeam/messages.ts | 100 ++++++++++++++++++ src/courseTeam/types.ts | 5 + 6 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 src/courseTeam/components/RolesContent.test.tsx diff --git a/src/courseTeam/components/RolesContent.test.tsx b/src/courseTeam/components/RolesContent.test.tsx new file mode 100644 index 00000000..3edf0222 --- /dev/null +++ b/src/courseTeam/components/RolesContent.test.tsx @@ -0,0 +1,45 @@ +import { screen } from '@testing-library/react'; +import { renderWithIntl } from '@src/testUtils'; +import RolesContent, { rolesOrder } from './RolesContent'; +import messages from '../messages'; +import { useRoles } from '../data/apiHook'; + +jest.mock('../data/apiHook', () => ({ + useRoles: jest.fn(), +})); + +const mockRoles = rolesOrder.map((role) => ({ id: role, name: messages[role].defaultMessage })); + +describe('RolesContent', () => { + it('renders all roles in the correct order with their descriptions', () => { + (useRoles as jest.Mock).mockReturnValue({ data: mockRoles }); + renderWithIntl(); + + rolesOrder.forEach((role) => { + expect(screen.getByText(messages[role].defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages[`${role}Description`].defaultMessage)).toBeInTheDocument(); + }); + }); + + it('does not render CCX Coach role when isCCXCoachEnabled is false', () => { + (useRoles as jest.Mock).mockReturnValue({ data: mockRoles }); + renderWithIntl(); + expect(screen.queryByText(messages.ccxCoach.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByText(messages.ccxCoachDescription.defaultMessage)).not.toBeInTheDocument(); + }); + + it('renders correct number of role sections', () => { + (useRoles as jest.Mock).mockReturnValue({ data: mockRoles }); + renderWithIntl(); + // There are 9 roles in rolesOrder + expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(9); + }); + + it('renders CCX Coach role when isCCXCoachEnabled is true', () => { + (useRoles as jest.Mock).mockReturnValue({ data: [...mockRoles, { id: 'ccxCoach', name: messages.ccxCoach.defaultMessage }] }); + + renderWithIntl(); + expect(screen.getByText(messages.ccxCoach.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.ccxCoachDescription.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/courseTeam/components/RolesContent.tsx b/src/courseTeam/components/RolesContent.tsx index 0d46b418..2df9399f 100644 --- a/src/courseTeam/components/RolesContent.tsx +++ b/src/courseTeam/components/RolesContent.tsx @@ -1,7 +1,44 @@ +import { useIntl } from '@openedx/frontend-base'; +import messages from '../messages'; +import { useParams } from 'react-router-dom'; +import { useRoles } from '../data/apiHook'; + +export const rolesOrder = [ + 'staff', + 'limitedStaff', + 'admin', + 'betaTesters', + 'courseDataResearchers', + 'discussionAdmin', + 'discussionModerator', + 'groupCommunityTA', + 'communityTA' +]; + const RolesContent = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const { data: roles = [] } = useRoles(courseId); + const isCCXCoachEnabled = roles.find((role) => role.id === 'ccxCoach'); + return (
- Roles content goes here. + { + rolesOrder.map((role) => ( +
+

{intl.formatMessage(messages[role])}

+

{intl.formatMessage(messages[`${role}Description`])}

+
+ )) + } + { + isCCXCoachEnabled && ( +
+

{intl.formatMessage(messages.ccxCoach)}

+

{intl.formatMessage(messages.ccxCoachDescription)}

+
+ ) + }
); }; diff --git a/src/courseTeam/data/api.ts b/src/courseTeam/data/api.ts index f813a043..ab6c069d 100644 --- a/src/courseTeam/data/api.ts +++ b/src/courseTeam/data/api.ts @@ -1,7 +1,7 @@ import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; import { getApiBaseUrl } from '../../data/api'; import { DataList } from '@src/types'; -import { CourseTeamMember, CourseTeamMemberQueryParams } from '../types'; +import { CourseTeamMember, CourseTeamMemberQueryParams, Role } from '../types'; export const getTeamMembers = async ( courseId: string, @@ -26,7 +26,7 @@ export const getTeamMembers = async ( return camelCaseObject(data); }; -export const getRoles = async (courseId: string): Promise => { +export const getRoles = async (courseId: string): Promise => { const { data } = await getAuthenticatedHttpClient().get( `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team_roles` ); diff --git a/src/courseTeam/data/apiHook.test.tsx b/src/courseTeam/data/apiHook.test.tsx index 642dad9d..c6f4ca8f 100644 --- a/src/courseTeam/data/apiHook.test.tsx +++ b/src/courseTeam/data/apiHook.test.tsx @@ -97,7 +97,7 @@ describe('apiHook', () => { describe('useRoles', () => { it('should fetch course roles successfully', async () => { - const mockRoles = ['instructor', 'staff', 'beta_testers']; + const mockRoles = [{ id: 'instructor', name: 'Instructor' }, { id: 'staff', name: 'Staff' }, { id: 'beta_testers', name: 'Beta Testers' }]; mockGetRoles.mockResolvedValue(mockRoles); diff --git a/src/courseTeam/messages.ts b/src/courseTeam/messages.ts index 2f7365a9..5acdfe70 100644 --- a/src/courseTeam/messages.ts +++ b/src/courseTeam/messages.ts @@ -51,6 +51,106 @@ const messages = defineMessages({ defaultMessage: 'No team members found.', description: 'Message displayed when there are no team members', }, + staff: { + id: 'instruct.courseTeam.roles.staff', + defaultMessage: 'Staff', + description: 'Role name for staff members', + }, + staffDescription: { + id: 'instruct.courseTeam.roles.staffDescription', + defaultMessage: 'Course team members with the Staff role help you manage your course. Staff can enroll and unenroll learners, as well as modify their grades and access all course data. Staff also have access to your course in Studio and Insights. Any users not yet enrolled in the course will be automatically enrolled when added as Staff.', + description: 'Description for staff role', + }, + limitedStaff: { + id: 'instruct.courseTeam.roles.limitedStaff', + defaultMessage: 'Limited Staff', + description: 'Role name for limited staff members', + }, + limitedStaffDescription: { + id: 'instruct.courseTeam.roles.limitedStaffDescription', + defaultMessage: 'Course team members with the Limited Staff role help you manage your course. Limited Staff can enroll and unenroll learners, as well as modify their grades and access all course data. Limited Staff don\'t have access to your course in Studio. Any users not yet enrolled in the course will be automatically enrolled when added as Limited Staff.', + description: 'Description for limited staff role', + }, + admin: { + id: 'instruct.courseTeam.roles.admin', + defaultMessage: 'Admin', + description: 'Role name for admin members', + }, + adminDescription: { + id: 'instruct.courseTeam.roles.adminDescription', + defaultMessage: 'Course team members with the Admin role help you manage your course. They can do all of the tasks that Staff can do, and can also add and remove the Staff and Admin roles, discussion moderation roles, and the beta tester role to manage course team membership. Any users not yet enrolled in the course will be automatically enrolled when added as Admin.', + description: 'Description for admin role', + }, + betaTesters: { + id: 'instruct.courseTeam.roles.betaTesters', + defaultMessage: 'Beta Testers', + description: 'Role name for beta tester members', + }, + betaTestersDescription: { + id: 'instruct.courseTeam.roles.betaTestersDescription', + defaultMessage: 'Beta Testers can see course content before other learners. They can make sure that the content works, but have no additional privileges. Any users not yet enrolled in the course will be automatically enrolled when added as Beta Tester.', + description: 'Description for beta tester role', + }, + courseDataResearchers: { + id: 'instruct.courseTeam.roles.courseDataResearchers', + defaultMessage: 'Course Data Researchers', + description: 'Role name for course data researcher members', + }, + courseDataResearchersDescription: { + id: 'instruct.courseTeam.roles.courseDataResearchersDescription', + defaultMessage: 'Course Data Researchers can access the data download tab. Any users not yet enrolled in the course will be automatically enrolled when added as Course Data Researcher.', + description: 'Description for course data researcher role', + }, + discussionAdmin: { + id: 'instruct.courseTeam.roles.discussionAdmin', + defaultMessage: 'Discussion Admin', + description: 'Role name for discussion admin members', + }, + discussionAdminDescription: { + id: 'instruct.courseTeam.roles.discussionAdminDescription', + defaultMessage: 'Discussion Admins can edit or delete any post, clear misuse flags, close and re-open threads, endorse responses, and see posts from all groups. Their posts are marked as \'staff\'. They can also add and remove the discussion moderation roles to manage course team membership. Any users not yet enrolled in the course will be automatically enrolled when added as Discussion Admin.', + description: 'Description for discussion admin role', + }, + discussionModerator: { + id: 'instruct.courseTeam.roles.discussionModerator', + defaultMessage: 'Discussion Moderator', + description: 'Role name for discussion moderator members', + }, + discussionModeratorDescription: { + id: 'instruct.courseTeam.roles.discussionModeratorDescription', + defaultMessage: 'Discussion Moderators can edit or delete any post, clear misuse flags, close and re-open threads, endorse responses, and see posts from all groups. Their posts are marked as \'staff\'. They cannot manage course team membership by adding or removing discussion moderation roles. Any users not yet enrolled in the course will be automatically enrolled when added as Discussion Moderator.', + description: 'Description for discussion moderator role', + }, + groupCommunityTA: { + id: 'instruct.courseTeam.roles.groupCommunityTA', + defaultMessage: 'Group Community TA', + description: 'Role name for group community TA members', + }, + groupCommunityTADescription: { + id: 'instruct.courseTeam.roles.groupCommunityTADescription', + defaultMessage: 'Group Community TAs are members of the community who help course teams moderate discussions. Group Community TAs see only posts by learners in their assigned group. They can edit or delete posts, clear flags, close and re-open threads, and endorse responses, but only for posts by learners in their group. Their posts are marked as \'Community TA\'. Any users not yet enrolled in the course will be automatically enrolled when added as Group Community TA.', + description: 'Description for group community TA role', + }, + communityTA: { + id: 'instruct.courseTeam.roles.communityTA', + defaultMessage: 'Community TA', + description: 'Role name for community TA members', + }, + communityTADescription: { + id: 'instruct.courseTeam.roles.communityTADescription', + defaultMessage: 'Community TAs are members of the community who help course teams moderate discussions. They can see posts by learners in their assigned cohort or enrollment track, and can edit or delete posts, clear flags, close or re-open threads, and endorse responses. Their posts are marked as \'Community TA\'. Any users not yet enrolled in the course will be automatically enrolled when added as Community TA.', + description: 'Description for community TA role', + }, + ccxCoach: { + id: 'instruct.courseTeam.roles.ccxCoach', + defaultMessage: 'CCX Coach', + description: 'Role name for CCX coach members', + }, + ccxCoachDescription: { + id: 'instruct.courseTeam.roles.ccxCoachDescription', + defaultMessage: 'CCX Coaches are able to create their own Custom Courses based on this course, which they can use to provide personalized instruction to their own students based in this course material.', + description: 'Description for CCX coach role', + } }); export default messages; diff --git a/src/courseTeam/types.ts b/src/courseTeam/types.ts index e016469e..d9e34bba 100644 --- a/src/courseTeam/types.ts +++ b/src/courseTeam/types.ts @@ -10,3 +10,8 @@ export interface CourseTeamMemberQueryParams { emailOrUsername?: string, role?: string, }; + +export interface Role { + id: string, + name: string, +};