Skip to content

Commit 673dcf3

Browse files
feat: course team filters
1 parent f059fa8 commit 673dcf3

File tree

11 files changed

+142
-51
lines changed

11 files changed

+142
-51
lines changed

src/app.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@
88
.text-prewrap {
99
white-space: pre-wrap;
1010
}
11+
12+
.username .form-control::placeholder {
13+
font-size: var(--pgn-typography-form-input-font-size-sm);
14+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { screen } from '@testing-library/react';
2+
import UsernameFilter from './UsernameFilter';
3+
import { renderWithIntl } from '../testUtils';
4+
import messages from './messages';
5+
6+
describe('UsernameFilter', () => {
7+
const setup = (filterValue = '', setFilter = jest.fn()) => {
8+
renderWithIntl(
9+
<UsernameFilter column={{ filterValue, setFilter }} />
10+
);
11+
return { setFilter };
12+
};
13+
14+
it('shows the placeholder correctly', () => {
15+
setup();
16+
expect(screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage)).toBeInTheDocument();
17+
});
18+
19+
it('shows the initial value correctly', () => {
20+
setup('value');
21+
expect(screen.getByDisplayValue('value')).toBeInTheDocument();
22+
});
23+
});

src/components/UsernameFilter.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useIntl } from '@openedx/frontend-base';
2+
import { FormControl, Icon } from '@openedx/paragon';
3+
import { Search } from '@openedx/paragon/icons';
4+
import { useDebouncedFilter } from '@src/hooks/useDebouncedFilter';
5+
import messages from './messages';
6+
7+
const UsernameFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => {
8+
const intl = useIntl();
9+
const { inputValue, handleChange } = useDebouncedFilter({
10+
filterValue,
11+
setFilter,
12+
});
13+
14+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
15+
handleChange(e.target.value);
16+
};
17+
18+
return (
19+
<FormControl
20+
className="mb-0 username"
21+
onChange={handleInputChange}
22+
placeholder={intl.formatMessage(messages.searchPlaceholder)}
23+
trailingElement={<Icon src={Search} />}
24+
value={inputValue}
25+
/>
26+
);
27+
};
28+
29+
export default UsernameFilter;

src/components/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ const messages = defineMessages({
116116
defaultMessage: 'Could not find student matching identifier: {identifier}',
117117
description: 'Error message displayed when a learner cannot be found based on the provided identifier (email or username)',
118118
},
119+
searchPlaceholder: {
120+
id: 'instruct.usernameFilter.searchPlaceholder',
121+
defaultMessage: 'Search By Username or Email',
122+
description: 'Placeholder text for the username filter input',
123+
}
119124
});
120125

121126
export default messages;

src/courseInfo/components/EnrollmentSummary/EnrollmentSummary.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ describe('EnrollmentSummary', () => {
4646
isLoading: false,
4747
});
4848
renderWithIntl(<EnrollmentSummary />);
49-
screen.debug();
5049

5150
expect(screen.getByText(messages.allEnrollmentsLabel.defaultMessage)).toBeInTheDocument();
5251
expect(screen.getByText(formatNumberWithCommas(mockCounter.enrollmentCounts.total))).toBeInTheDocument();

src/courseTeam/components/MembersContent.test.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { renderWithIntl } from '@src/testUtils';
4-
import { useTeamMembers } from '../data/apiHook';
4+
import { useRoles, useTeamMembers } from '../data/apiHook';
55
import MembersContent from './MembersContent';
66
import messages from '../messages';
77

88
const courseId = 'course-v1:edX+DemoX+Demo_Course';
99

1010
jest.mock('../data/apiHook', () => ({
1111
useTeamMembers: jest.fn(),
12+
useRoles: jest.fn(),
1213
}));
1314

1415
jest.mock('react-router-dom', () => ({
@@ -21,11 +22,14 @@ const mockTeamMembers = [
2122
{ username: 'user2', email: 'user2@example.com', role: 'Staff' },
2223
];
2324

25+
const mockRoles = { results: [{ role: 'Admin', displayName: 'Admin' }, { role: 'Staff', displayName: 'Staff' }] };
26+
2427
const renderComponent = () => renderWithIntl(<MembersContent />);
2528

2629
describe('MembersContent', () => {
2730
beforeEach(() => {
2831
jest.clearAllMocks();
32+
(useRoles as jest.Mock).mockReturnValue({ data: mockRoles, isLoading: false });
2933
});
3034

3135
it('renders loading state correctly', () => {
@@ -48,10 +52,10 @@ describe('MembersContent', () => {
4852

4953
expect(screen.getByText(mockTeamMembers[0].username)).toBeInTheDocument();
5054
expect(screen.getByText(mockTeamMembers[0].email)).toBeInTheDocument();
51-
expect(screen.getByText(mockTeamMembers[0].role)).toBeInTheDocument();
55+
expect(screen.getByRole('cell', { name: mockTeamMembers[0].role })).toBeInTheDocument();
5256
expect(screen.getByText(mockTeamMembers[1].username)).toBeInTheDocument();
5357
expect(screen.getByText(mockTeamMembers[1].email)).toBeInTheDocument();
54-
expect(screen.getByText(mockTeamMembers[1].role)).toBeInTheDocument();
58+
expect(screen.getByRole('cell', { name: mockTeamMembers[1].role })).toBeInTheDocument();
5559
});
5660

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

src/courseTeam/components/MembersContent.tsx

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,59 @@
1-
import { useState, useCallback, useMemo } from 'react';
1+
import { useState, useMemo } from 'react';
22
import { useParams } from 'react-router-dom';
33
import { useIntl } from '@openedx/frontend-base';
4-
import { Button, DataTable } from '@openedx/paragon';
4+
import { Button, DataTable, FormControl, Icon } from '@openedx/paragon';
55
import messages from '../messages';
6-
import { useTeamMembers } from '../data/apiHook';
6+
import { useRoles, useTeamMembers } from '../data/apiHook';
7+
import UsernameFilter from '@src/components/UsernameFilter';
8+
import { FilterList } from '@openedx/paragon/icons';
9+
import { DataTableFetchDataProps } from '@src/types';
10+
import { Role } from '../types';
711

812
const TEAM_MEMBERS_PAGE_SIZE = 25;
913

14+
const RoleFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => {
15+
const intl = useIntl();
16+
const { courseId = '' } = useParams<{ courseId: string }>();
17+
const { data } = useRoles(courseId);
18+
19+
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
20+
setFilter(e.target.value);
21+
};
22+
23+
const roles = useMemo(() => {
24+
return [{ value: '', label: intl.formatMessage(messages.allRoles) }, ...(data?.results || []).map((role: Role) => ({ value: role.role, label: role.displayName }))];
25+
}, [data, intl]);
26+
27+
return (
28+
<FormControl
29+
as="select"
30+
className="mb-0"
31+
disabled={!data}
32+
name="role"
33+
size="md"
34+
value={filterValue}
35+
onChange={handleSelectChange}
36+
leadingElement={<Icon src={FilterList} />}
37+
>
38+
{roles.map(role => (
39+
<option key={role.value} value={role.value}>
40+
{role.label}
41+
</option>
42+
))}
43+
</FormControl>
44+
);
45+
};
46+
1047
const MembersContent = () => {
1148
const intl = useIntl();
1249
const { courseId = '' } = useParams<{ courseId: string }>();
1350
const [filters, setFilters] = useState({ page: 0, emailOrUsername: '', role: '' });
1451
const { data: { results: teamMembers = [], numPages = 1, count = 0 } = {}, isLoading = false } = useTeamMembers(courseId, { ...filters, pageSize: TEAM_MEMBERS_PAGE_SIZE });
1552

1653
const tableColumns = useMemo(() => [
17-
{ accessor: 'username', Header: intl.formatMessage(messages.username) },
18-
{ accessor: 'email', Header: intl.formatMessage(messages.email) },
19-
{ accessor: 'role', Header: intl.formatMessage(messages.role) },
54+
{ accessor: 'username', Header: intl.formatMessage(messages.username), Filter: UsernameFilter },
55+
{ accessor: 'email', Header: intl.formatMessage(messages.email), disableFilters: true },
56+
{ accessor: 'role', Header: intl.formatMessage(messages.role), Filter: RoleFilter },
2057
], [intl]);
2158

2259
const additionalColumns = useMemo(() => [{
@@ -29,17 +66,27 @@ const MembersContent = () => {
2966
)
3067
}], [intl]);
3168

32-
const handleFetchData = useCallback(({ pageIndex, filters: tableFilters }: { pageIndex: number, filters: { id: string, value: string }[] }) => {
33-
// Filters will be handled in a future iteration, for now we will just update pagination
34-
console.log(pageIndex, tableFilters);
35-
if (pageIndex !== filters.page) {
36-
setFilters(prevFilters => ({
69+
const handleFetchData = (data: DataTableFetchDataProps) => {
70+
const usernameFilter = data.filters?.find((f) => f.id === 'username');
71+
const newEmailOrUsername = usernameFilter ? usernameFilter.value : '';
72+
const rolesFilter = data.filters?.find((f) => f.id === 'role');
73+
const newRole = rolesFilter ? rolesFilter.value : '';
74+
const filtersChanged = (newEmailOrUsername !== filters.emailOrUsername) || (newRole !== filters.role);
75+
76+
if (filtersChanged) {
77+
setFilters((prevFilters) => ({
3778
...prevFilters,
38-
page: pageIndex,
79+
emailOrUsername: newEmailOrUsername,
80+
role: newRole,
81+
page: 0,
3982
}));
83+
return;
84+
}
85+
86+
if (data.pageIndex !== filters.page) {
87+
setFilters((prevFilters) => ({ ...prevFilters, page: data.pageIndex }));
4088
}
41-
// eslint-disable-next-line react-hooks/exhaustive-deps
42-
}, []);
89+
};
4390

4491
const tableState = useMemo(() => ({
4592
pageIndex: filters.page,
@@ -53,11 +100,13 @@ const MembersContent = () => {
53100
data={teamMembers}
54101
fetchData={handleFetchData}
55102
state={tableState}
103+
isFilterable
56104
isLoading={isLoading}
57105
isPaginated
58106
itemCount={count}
59107
manualFilters
60108
manualPagination
109+
numBreakoutFilters={2}
61110
pageSize={TEAM_MEMBERS_PAGE_SIZE}
62111
pageCount={numPages}
63112
RowStatusComponent={() => null}

src/courseTeam/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ const messages = defineMessages({
5151
defaultMessage: 'No team members found.',
5252
description: 'Message displayed when there are no team members',
5353
},
54+
allRoles: {
55+
id: 'instruct.courseTeam.allRoles',
56+
defaultMessage: 'All Roles',
57+
description: 'Option label for filtering by all roles',
58+
},
5459
});
5560

5661
export default messages;

src/dateExtensions/components/DateExtensionsList.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ describe('DateExtensionsList', () => {
8686
renderComponent({});
8787
const user = userEvent.setup();
8888

89-
const searchInput = screen.getByPlaceholderText(/search for a learner/i);
89+
const searchInput = screen.getByPlaceholderText(/search by username or email/i);
9090
expect(searchInput).toBeInTheDocument();
9191

9292
await user.type(searchInput, 'test_user');
@@ -106,7 +106,7 @@ describe('DateExtensionsList', () => {
106106
renderComponent({});
107107
const user = userEvent.setup();
108108

109-
const searchInput = screen.getByPlaceholderText(/search for a learner/i);
109+
const searchInput = screen.getByPlaceholderText(/search by username or email/i);
110110

111111
// Type multiple characters quickly
112112
await user.type(searchInput, 'test');
@@ -171,7 +171,7 @@ describe('DateExtensionsList', () => {
171171
}
172172

173173
// Now apply a filter
174-
const searchInput = screen.getByPlaceholderText(/search for a learner/i);
174+
const searchInput = screen.getByPlaceholderText(/search by username or email/i);
175175
await user.type(searchInput, 'test');
176176

177177
// Should reset to page 0 when filter changes
@@ -217,7 +217,7 @@ describe('DateExtensionsList', () => {
217217
const user = userEvent.setup();
218218

219219
// Apply a filter first
220-
const searchInput = screen.getByPlaceholderText(/search for a learner/i);
220+
const searchInput = screen.getByPlaceholderText(/search by username or email/i);
221221
await user.type(searchInput, 'test');
222222

223223
// Wait for the filter to be applied

src/dateExtensions/components/DateExtensionsList.tsx

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { useState } from 'react';
22
import { useParams } from 'react-router-dom';
33
import { useIntl } from '@openedx/frontend-base';
4-
import { Button, DataTable, FormControl, Icon } from '@openedx/paragon';
4+
import { Button, DataTable } from '@openedx/paragon';
55
import messages from '../messages';
66
import { LearnerDateExtension } from '../types';
77
import { useDateExtensions } from '../data/apiHook';
8-
import { Search } from '@openedx/paragon/icons';
98
import SelectGradedSubsection from './SelectGradedSubsection';
109
import { useDebouncedFilter } from '../../hooks/useDebouncedFilter';
1110
import { DataTableFetchDataProps } from '@src/types';
11+
import UsernameFilter from '@src/components/UsernameFilter';
1212

1313
const DATE_EXTENSIONS_PAGE_SIZE = 25;
1414

@@ -17,28 +17,6 @@ export interface DateExtensionListProps {
1717
onClickAdd?: () => void,
1818
}
1919

20-
const UsernameFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => {
21-
const intl = useIntl();
22-
const { inputValue, handleChange } = useDebouncedFilter({
23-
filterValue,
24-
setFilter,
25-
});
26-
27-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
28-
handleChange(e.target.value);
29-
};
30-
31-
return (
32-
<FormControl
33-
className="mb-0"
34-
onChange={handleInputChange}
35-
placeholder={intl.formatMessage(messages.searchLearnerPlaceholder)}
36-
trailingElement={<Icon src={Search} />}
37-
value={inputValue}
38-
/>
39-
);
40-
};
41-
4220
const GradedSubsectionFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => {
4321
const intl = useIntl();
4422
const { inputValue, handleChange } = useDebouncedFilter({

0 commit comments

Comments
 (0)