Skip to content

Commit 30bcc68

Browse files
feat: edit team member modal
1 parent 8cede54 commit 30bcc68

File tree

8 files changed

+324
-17
lines changed

8 files changed

+324
-17
lines changed

src/courseTeam/CourseTeamPage.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1+
import { useState } from 'react';
12
import { useIntl } from '@openedx/frontend-base';
23
import { Button, Tab, Tabs, useToggle } from '@openedx/paragon';
34
import { Plus } from '@openedx/paragon/icons';
45
import AddTeamMemberModal from '@src/courseTeam/components/AddTeamMemberModal';
6+
import EditTeamMemberModal from '@src/courseTeam/components/EditTeamMemberModal';
57
import MembersContent from '@src/courseTeam/components/MembersContent';
68
import RolesContent from '@src/courseTeam/components/RolesContent';
79
import messages from '@src/courseTeam/messages';
10+
import { CourseTeamMember } from '@src/courseTeam/types';
811

912
const CourseTeamPage = () => {
1013
const intl = useIntl();
1114
const [isOpenAddModal, openAddModal, closeAddModal] = useToggle(false);
15+
const [isOpenEditModal, openEditModal, closeEditModal] = useToggle(false);
16+
const [selectedUser, setSelectedUser] = useState<CourseTeamMember | null>(null);
17+
18+
const handleEdit = (user: CourseTeamMember) => {
19+
setSelectedUser(user);
20+
openEditModal();
21+
};
1222

1323
return (
1424
<>
@@ -18,13 +28,14 @@ const CourseTeamPage = () => {
1828
</div>
1929
<Tabs>
2030
<Tab eventKey="members" title={intl.formatMessage(messages.membersTab)}>
21-
<MembersContent />
31+
<MembersContent onEdit={handleEdit} />
2232
</Tab>
2333
<Tab eventKey="roles" title={intl.formatMessage(messages.rolesTab)}>
2434
<RolesContent />
2535
</Tab>
2636
</Tabs>
2737
{isOpenAddModal && <AddTeamMemberModal isOpen={isOpenAddModal} onClose={closeAddModal} />}
38+
{isOpenEditModal && selectedUser && <EditTeamMemberModal isOpen={isOpenEditModal} user={selectedUser} onClose={closeEditModal} />}
2839
</>
2940
);
3041
};
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { renderWithIntl } from '@src/testUtils';
4+
import EditTeamMemberModal from './EditTeamMemberModal';
5+
import messages from '../messages';
6+
import { CourseTeamMember } from '../types';
7+
import { useRoles } from '../data/apiHook';
8+
9+
// Mocks
10+
jest.mock('react-router-dom', () => ({
11+
useParams: () => ({ courseId: 'course-v1:test+course+run' }),
12+
}));
13+
14+
jest.mock('../data/apiHook', () => ({
15+
useRoles: jest.fn(),
16+
}));
17+
18+
const mockUser: CourseTeamMember = {
19+
username: 'test_user',
20+
fullName: 'Test User',
21+
email: 'test@example.com',
22+
roles: ['Staff', 'Admin'],
23+
};
24+
25+
const mockRoles = [
26+
{ id: 'instructor', name: 'Instructor' },
27+
{ id: 'staff', name: 'Staff' },
28+
{ id: 'admin', name: 'Admin' },
29+
{ id: 'beta_testers', name: 'Beta Testers' },
30+
{ id: 'data_researcher', name: 'Data Researcher' },
31+
];
32+
33+
describe('EditTeamMemberModal', () => {
34+
const defaultProps = {
35+
isOpen: true,
36+
user: mockUser,
37+
onClose: jest.fn(),
38+
};
39+
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
(useRoles as jest.Mock).mockReturnValue({ data: mockRoles });
43+
});
44+
45+
it('renders modal with correct title', () => {
46+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
47+
48+
const expectedTitle = messages.editTeamTitle.defaultMessage.replace('{username}', mockUser.username);
49+
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
50+
});
51+
52+
it('renders modal header and body correctly', () => {
53+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
54+
55+
const expectedTitle = messages.editTeamTitle.defaultMessage.replace('{username}', mockUser.username);
56+
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
57+
58+
// Check that header has correct styling
59+
const headerElement = screen.getByText(expectedTitle);
60+
expect(headerElement).toHaveClass('text-white');
61+
});
62+
63+
it('renders edit instructions with username', () => {
64+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
65+
66+
const expectedInstructions = messages.editInstructions.defaultMessage.replace('{username}', mockUser.username);
67+
expect(screen.getByText(expectedInstructions)).toBeInTheDocument();
68+
});
69+
70+
it('renders current user roles as checkboxes', () => {
71+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
72+
73+
mockUser.roles.forEach((role) => {
74+
expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
75+
});
76+
});
77+
78+
it('renders add role label', () => {
79+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
80+
81+
expect(screen.getByText(messages.addRole.defaultMessage)).toBeInTheDocument();
82+
});
83+
84+
it('renders role selection dropdown with filtered roles', () => {
85+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
86+
87+
const selectElement = screen.getByRole('combobox');
88+
expect(selectElement).toBeInTheDocument();
89+
90+
// Verify placeholder is present
91+
expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
92+
93+
// Verify only roles not already assigned to user are available
94+
const availableRoles = mockRoles.filter(role => !mockUser.roles.includes(role.name));
95+
availableRoles.forEach((role) => {
96+
expect(screen.getByRole('option', { name: role.name })).toBeInTheDocument();
97+
});
98+
99+
// Verify user's current roles are not in the dropdown options
100+
mockUser.roles.forEach((roleName) => {
101+
const roleInMockData = mockRoles.find(role => role.name === roleName);
102+
if (roleInMockData) {
103+
expect(screen.queryByRole('option', { name: roleName })).not.toBeInTheDocument();
104+
}
105+
});
106+
});
107+
108+
it('renders cancel and save buttons', () => {
109+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
110+
111+
expect(screen.getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
112+
expect(screen.getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
113+
});
114+
115+
it('calls onClose when cancel button is clicked', async () => {
116+
const mockOnClose = jest.fn();
117+
renderWithIntl(<EditTeamMemberModal {...defaultProps} onClose={mockOnClose} />);
118+
119+
const user = userEvent.setup();
120+
const cancelButton = screen.getByRole('button', { name: messages.cancelButton.defaultMessage });
121+
await user.click(cancelButton);
122+
123+
expect(mockOnClose).toHaveBeenCalledTimes(1);
124+
});
125+
126+
it('calls onClose when save button is clicked', async () => {
127+
const mockOnClose = jest.fn();
128+
renderWithIntl(<EditTeamMemberModal {...defaultProps} onClose={mockOnClose} />);
129+
130+
const user = userEvent.setup();
131+
const saveButton = screen.getByRole('button', { name: messages.saveButton.defaultMessage });
132+
await user.click(saveButton);
133+
134+
expect(mockOnClose).toHaveBeenCalledTimes(1);
135+
});
136+
137+
it('does not render when isOpen is false', () => {
138+
renderWithIntl(<EditTeamMemberModal {...defaultProps} isOpen={false} />);
139+
140+
const expectedTitle = messages.editTeamTitle.defaultMessage.replace('{username}', mockUser.username);
141+
expect(screen.queryByText(expectedTitle)).not.toBeInTheDocument();
142+
});
143+
144+
it('renders correctly when no roles data is available', () => {
145+
(useRoles as jest.Mock).mockReturnValue({ data: [] });
146+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
147+
148+
// Should only show placeholder in dropdown
149+
expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
150+
151+
// Select should be disabled when no roles are available
152+
const selectElement = screen.getByRole('combobox');
153+
expect(selectElement).toBeDisabled();
154+
155+
// Should still show current user roles as checkboxes
156+
mockUser.roles.forEach((role) => {
157+
expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
158+
});
159+
});
160+
161+
it('renders correctly when useRoles returns undefined data', () => {
162+
(useRoles as jest.Mock).mockReturnValue({ data: undefined });
163+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
164+
165+
// Should only show placeholder in dropdown
166+
expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
167+
168+
// Select should be disabled when no roles are available
169+
const selectElement = screen.getByRole('combobox');
170+
expect(selectElement).toBeDisabled();
171+
172+
// Should still show current user roles as checkboxes
173+
mockUser.roles.forEach((role) => {
174+
expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
175+
});
176+
});
177+
178+
it('handles user with all available roles assigned', () => {
179+
const userWithAllRoles = {
180+
...mockUser,
181+
roles: mockRoles.map(role => role.name),
182+
};
183+
renderWithIntl(<EditTeamMemberModal {...defaultProps} user={userWithAllRoles} />);
184+
185+
// Should show all roles as checkboxes
186+
userWithAllRoles.roles.forEach((role) => {
187+
expect(screen.getByRole('checkbox', { name: role })).toBeInTheDocument();
188+
});
189+
190+
// Dropdown should only have placeholder since all roles are assigned
191+
expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
192+
const options = screen.getAllByRole('option');
193+
expect(options).toHaveLength(1); // Only placeholder option
194+
});
195+
196+
it('shows select role placeholder in dropdown', () => {
197+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
198+
199+
const selectElement = screen.getByRole('combobox');
200+
expect(selectElement).toHaveAttribute('placeholder', messages.rolePlaceholder.defaultMessage);
201+
});
202+
203+
it('enables select when roles are available for assignment', () => {
204+
renderWithIntl(<EditTeamMemberModal {...defaultProps} />);
205+
206+
// Should be enabled when there are roles available to assign
207+
const selectElement = screen.getByRole('combobox');
208+
expect(selectElement).not.toBeDisabled();
209+
});
210+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useParams } from 'react-router-dom';
2+
import { ActionRow, Button, FormControl, FormLabel, ModalDialog } from '@openedx/paragon';
3+
import { useIntl } from '@openedx/frontend-base';
4+
import messages from '../messages';
5+
import { CourseTeamMember } from '../types';
6+
import { useRoles } from '../data/apiHook';
7+
import { FormCheckboxSet, FormCheckbox } from '@openedx/paragon/dist/Form';
8+
9+
interface EditTeamMemberModalProps {
10+
isOpen: boolean,
11+
user: CourseTeamMember,
12+
onClose: () => void,
13+
}
14+
15+
const EditTeamMemberModal = ({ isOpen, user, onClose }: EditTeamMemberModalProps) => {
16+
const intl = useIntl();
17+
const { courseId = '' } = useParams<{ courseId: string }>();
18+
19+
const { data = [] } = useRoles(courseId);
20+
21+
const filteredRoles = data?.filter(role => !user.roles.includes(role.name)) || [];
22+
23+
const roles = [{ id: '', name: intl.formatMessage(messages.rolePlaceholder) }, ...filteredRoles];
24+
25+
return (
26+
<ModalDialog
27+
isOpen={isOpen}
28+
title={intl.formatMessage(messages.editTeamTitle, { username: user.username })}
29+
onClose={onClose}
30+
isOverflowVisible={false}
31+
>
32+
<ModalDialog.Header className="bg-primary-500">
33+
<h3 className="text-white">{intl.formatMessage(messages.editTeamTitle, { username: user.username })}</h3>
34+
</ModalDialog.Header>
35+
<ModalDialog.Body className="p-4">
36+
<p>{intl.formatMessage(messages.editInstructions, { username: user.username })}</p>
37+
{
38+
user.roles.map((role) => (
39+
<FormCheckboxSet key={role} className="mt-2" name={`role-${role}`}>
40+
<FormCheckbox>{role}</FormCheckbox>
41+
</FormCheckboxSet>
42+
))
43+
}
44+
<FormLabel className="mt-4">{intl.formatMessage(messages.addRole)}</FormLabel>
45+
<FormControl as="select" placeholder={intl.formatMessage(messages.rolePlaceholder)} disabled={roles.length === 1}>
46+
{
47+
roles.map((role) => (
48+
<option key={role.id} value={role.id}>
49+
{role.name}
50+
</option>
51+
))
52+
}
53+
</FormControl>
54+
</ModalDialog.Body>
55+
<ModalDialog.Footer className="p-4">
56+
<ActionRow>
57+
<Button variant="tertiary" onClick={onClose}>{intl.formatMessage(messages.cancelButton)}</Button>
58+
<Button variant="primary" onClick={onClose}>{intl.formatMessage(messages.saveButton)}</Button>
59+
</ActionRow>
60+
</ModalDialog.Footer>
61+
</ModalDialog>
62+
);
63+
};
64+
65+
export default EditTeamMemberModal;

src/courseTeam/components/MembersContent.test.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ jest.mock('react-router-dom', () => ({
1818
}));
1919

2020
const mockTeamMembers = [
21-
{ username: 'user1', email: 'user1@example.com', role: 'Admin' },
22-
{ username: 'user2', email: 'user2@example.com', role: 'Staff' },
21+
{ username: 'user1', fullName: 'User One', email: 'user1@example.com', roles: ['Admin'] },
22+
{ username: 'user2', fullName: 'User Two', email: 'user2@example.com', roles: ['Staff'] },
2323
];
2424

2525
const mockRoles = { results: [{ role: 'Admin', displayName: 'Admin' }, { role: 'Staff', displayName: 'Staff' }] };
26+
const mockOnEdit = jest.fn();
2627

27-
const renderComponent = () => renderWithIntl(<MembersContent />);
28+
const renderComponent = () => renderWithIntl(<MembersContent onEdit={mockOnEdit} />);
2829

2930
describe('MembersContent', () => {
3031
beforeEach(() => {
@@ -52,10 +53,10 @@ describe('MembersContent', () => {
5253

5354
expect(screen.getByText(mockTeamMembers[0].username)).toBeInTheDocument();
5455
expect(screen.getByText(mockTeamMembers[0].email)).toBeInTheDocument();
55-
expect(screen.getByRole('cell', { name: mockTeamMembers[0].role })).toBeInTheDocument();
56+
expect(screen.getByRole('cell', { name: mockTeamMembers[0].roles.join(', ') })).toBeInTheDocument();
5657
expect(screen.getByText(mockTeamMembers[1].username)).toBeInTheDocument();
5758
expect(screen.getByText(mockTeamMembers[1].email)).toBeInTheDocument();
58-
expect(screen.getByRole('cell', { name: mockTeamMembers[1].role })).toBeInTheDocument();
59+
expect(screen.getByRole('cell', { name: mockTeamMembers[1].roles.join(', ') })).toBeInTheDocument();
5960
});
6061

6162
it('renders empty state when no team members', () => {

src/courseTeam/components/MembersContent.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ import { FilterList } from '@openedx/paragon/icons';
66
import UsernameFilter from '@src/components/UsernameFilter';
77
import { useRoles, useTeamMembers } from '@src/courseTeam/data/apiHook';
88
import messages from '@src/courseTeam/messages';
9-
import { Role } from '@src/courseTeam/types';
9+
import { CourseTeamMember, Role } from '@src/courseTeam/types';
1010
import { DataTableFetchDataProps } from '@src/types';
1111

1212
const TEAM_MEMBERS_PAGE_SIZE = 25;
1313

14+
interface MembersContentProps {
15+
onEdit: (user: CourseTeamMember) => void,
16+
}
17+
1418
const RoleFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => {
1519
const intl = useIntl();
1620
const { courseId = '' } = useParams<{ courseId: string }>();
@@ -44,7 +48,7 @@ const RoleFilter = ({ column: { filterValue, setFilter } }: { column: { filterVa
4448
);
4549
};
4650

47-
const MembersContent = () => {
51+
const MembersContent = ({ onEdit }: MembersContentProps) => {
4852
const intl = useIntl();
4953
const { courseId = '' } = useParams<{ courseId: string }>();
5054
const [filters, setFilters] = useState({ page: 0, emailOrUsername: '', role: '' });
@@ -53,18 +57,18 @@ const MembersContent = () => {
5357
const tableColumns = useMemo(() => [
5458
{ accessor: 'username', Header: intl.formatMessage(messages.username), Filter: UsernameFilter },
5559
{ accessor: 'email', Header: intl.formatMessage(messages.email), disableFilters: true },
56-
{ accessor: 'role', Header: intl.formatMessage(messages.role), Filter: RoleFilter },
60+
{ accessor: 'roles', Header: intl.formatMessage(messages.role), Cell: ({ cell: { value } }: { cell: { value: string[] } }) => value.join(', '), Filter: RoleFilter },
5761
], [intl]);
5862

5963
const additionalColumns = useMemo(() => [{
6064
id: 'actions',
6165
Header: intl.formatMessage(messages.actions),
62-
Cell: () => (
63-
<Button variant="link" size="inline">
66+
Cell: ({ row }: { row: { original: any } }) => (
67+
<Button variant="link" size="inline" onClick={() => onEdit(row.original)}>
6468
{intl.formatMessage(messages.edit)}
6569
</Button>
6670
)
67-
}], [intl]);
71+
}], [intl, onEdit]);
6872

6973
const handleFetchData = (data: DataTableFetchDataProps) => {
7074
const usernameFilter = data.filters?.find((f) => f.id === 'username');

0 commit comments

Comments
 (0)