Skip to content

Commit 798b19d

Browse files
feat: adding filtering and sorting to audit user table
1 parent 7fd7a80 commit 798b19d

17 files changed

Lines changed: 692 additions & 17 deletions

src/authz-module/audit-user/index.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo } from 'react';
1+
import { useMemo, useState } from 'react';
22
import { useIntl } from '@edx/frontend-platform/i18n';
33
import debounce from 'lodash.debounce';
44
import {
@@ -14,8 +14,13 @@ import AddRoleButton from '@src/authz-module/components/AddRoleButton';
1414
import { RoleCell } from '@src/authz-module/components/TableCells';
1515
import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
1616
import { useUserAssignedRoles } from '@src/authz-module/data/hooks';
17-
import messages from './messages';
17+
import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar';
18+
import RolesFilter from 'authz-module/components/TableControlBar/RolesFilter';
19+
import OrgFilter from 'authz-module/components/TableControlBar/OrgFilter';
20+
import ScopesFilter from 'authz-module/components/TableControlBar/ScopesFilter';
1821
import { ViewAllPermissionsCell, ActionsCell, PermissionsCell } from './CustomCells';
22+
import messages from './messages';
23+
import { getCellHeader } from './utils';
1924

2025
const dummyData = [
2126
{
@@ -106,6 +111,7 @@ const dummyData = [
106111

107112
const AuditUserPage = () => {
108113
const { formatMessage } = useIntl();
114+
const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState<string[]>([]);
109115
const { username } = useParams();
110116
const navigate = useNavigate();
111117
const { isLoading: isLoadingUser, data: user } = useUserAccount(username ?? '');
@@ -136,18 +142,28 @@ const AuditUserPage = () => {
136142
];
137143
const columns = [
138144
{
139-
Header: formatMessage(messages['authz.user.table.role.column.header']),
145+
Header: getCellHeader('role', formatMessage(messages['authz.user.table.role.column.header']), columnsWithFiltersApplied),
140146
accessor: 'role',
141147
Cell: RoleCell,
148+
filter: 'includesValue',
149+
Filter: RolesFilter,
150+
filterButtonText: formatMessage(messages['authz.user.table.role.column.header']),
151+
filterOrder: 2,
142152
},
143153
{
144-
Header: formatMessage(messages['authz.user.table.organization.column.header']),
154+
Header: getCellHeader('organization', formatMessage(messages['authz.user.table.organization.column.header']), columnsWithFiltersApplied),
145155
accessor: 'organization',
156+
filter: 'includesValue',
157+
Filter: OrgFilter,
158+
filterButtonText: formatMessage(messages['authz.user.table.organization.column.header']),
159+
filterOrder: 1,
160+
146161
},
147162
{
148-
Header: formatMessage(messages['authz.user.table.scope.column.header']),
163+
Header: getCellHeader('scope', formatMessage(messages['authz.user.table.scope.column.header']), columnsWithFiltersApplied),
149164
accessor: 'scope',
150165
disableFilters: true,
166+
151167
},
152168
{
153169
Header: formatMessage(messages['authz.user.table.permissions.column.header']),
@@ -181,7 +197,11 @@ const AuditUserPage = () => {
181197
<Container className="bg-light-200 p-5">
182198
<DataTable
183199
isPaginated
200+
isFilterable
201+
isSortable
184202
manualPagination
203+
manualFilters
204+
manualSortBy
185205
data={dummyData}
186206
fetchData={fetchData}
187207
itemCount={dummyData?.length || 0}
@@ -190,6 +210,7 @@ const AuditUserPage = () => {
190210
additionalColumns={additionalColumns}
191211
columns={columns}
192212
>
213+
<TableControlBar onFilterChange={setColumnsWithFiltersApplied} />
193214
<DataTable.Table />
194215
<TableFooter />
195216
</DataTable>

src/authz-module/audit-user/utils.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Icon } from '@openedx/paragon';
2+
import { FilterList } from '@openedx/paragon/icons';
3+
4+
export const getPermissionsCountByRole = (/* role: string */) => {
5+
/*
6+
const roleData = permissionsList.find(item => item.role === role);
7+
return roleData ? roleData.permissions.length : 0;
8+
*/
9+
const count = Math.floor(Math.random() * 50);
10+
return count;
11+
};
12+
13+
export const getCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
14+
if (filtersApplied.includes(columnId)) {
15+
return (
16+
<span className="d-flex flex-row align-items-center">
17+
<Icon src={FilterList} size="sm" className="mr-2" />
18+
{columnTitle}
19+
</span>
20+
);
21+
}
22+
return columnTitle;
23+
};
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
Dropdown, Form, Icon, Stack,
3+
} from '@openedx/paragon';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
5+
import { FilterList, Search } from '@openedx/paragon/icons';
6+
import { useState } from 'react';
7+
import messages from '../messages';
8+
import { MultipleChoiceFilterProps } from './types';
9+
10+
const MultipleChoiceFilter = ({
11+
filterButtonText,
12+
filterChoices,
13+
filterValue,
14+
setFilter,
15+
isGrouped = false,
16+
isSearchable = false,
17+
onSearchChange,
18+
iconSrc,
19+
disabled = false,
20+
}: MultipleChoiceFilterProps) => {
21+
const [searchValue, setSearchValue] = useState<string | undefined>(undefined);
22+
const { formatMessage } = useIntl();
23+
const checkedBoxes = filterValue || [];
24+
const handleClickCheckbox = (value) => {
25+
const newValue = {
26+
groupName: filterButtonText?.toLocaleLowerCase() || '',
27+
value,
28+
displayName: value,
29+
};
30+
if (checkedBoxes.includes(value)) {
31+
const newCheckedBoxes = checkedBoxes.filter((val) => val !== value);
32+
return setFilter(newCheckedBoxes, newValue);
33+
}
34+
checkedBoxes.push(value);
35+
return setFilter(checkedBoxes, newValue);
36+
};
37+
38+
const getGroupedChoices = () => {
39+
const groupedFilterChoices = filterChoices.reduce((groups, choice) => {
40+
const groupName = choice.groupName || 'Ungrouped';
41+
const icon = choice.groupIcon || undefined;
42+
if (!groups.has(groupName)) {
43+
groups.set(groupName, { groupName, options: [], icon });
44+
}
45+
groups.get(groupName)!.options.push({
46+
displayName: choice.displayName,
47+
value: choice.value,
48+
});
49+
return groups;
50+
}, new Map<string, { groupName: string; options: Array<{ displayName: string; value: string }>; icon?: any }>());
51+
return Array.from(groupedFilterChoices.values());
52+
};
53+
return (
54+
<Dropdown className="no-caret-dropdown filters">
55+
<Dropdown.Toggle variant={checkedBoxes.length > 0 ? 'primary' : 'outline-primary'}>
56+
<Stack direction="horizontal" gap={2}>
57+
{iconSrc && <Icon color="primary" src={iconSrc} />}
58+
{filterButtonText}
59+
{checkedBoxes.length > 0 && ` (${checkedBoxes.length})`}
60+
<Icon color="primary" src={FilterList} />
61+
</Stack>
62+
</Dropdown.Toggle>
63+
64+
<Dropdown.Menu>
65+
{isSearchable && (
66+
<Form.Control
67+
className="m-1"
68+
type="text"
69+
trailingElement={<Icon src={Search} />}
70+
placeholder={formatMessage(messages['authz.table.controlbar.search'])}
71+
onChange={(e) => {
72+
setSearchValue(e.target.value);
73+
onSearchChange?.(e.target.value);
74+
}}
75+
value={searchValue}
76+
/>
77+
)}
78+
<Form.CheckboxSet
79+
className="pgn__dropdown-filter-checkbox-group"
80+
name={filterButtonText}
81+
aria-label={filterButtonText}
82+
value={checkedBoxes}
83+
>
84+
{/** TODO: Change for actual values */}
85+
<span className="small text-info-700 mt-2">{formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })}</span>
86+
{!isGrouped ? filterChoices.map(({
87+
displayName, value,
88+
}) => (
89+
<Form.Checkbox
90+
className="m-2"
91+
key={displayName}
92+
checked={checkedBoxes.includes(value)}
93+
value={value}
94+
onChange={() => handleClickCheckbox(value)}
95+
aria-label={displayName}
96+
disabled={checkedBoxes.includes(value) ? false : disabled}
97+
>
98+
<span className="small">{displayName}</span>
99+
</Form.Checkbox>
100+
))
101+
: getGroupedChoices().map(({ groupName, icon, options }) => (
102+
<div key={groupName}>
103+
<div className="pgn__dropdown-filter-group-name text-info-700 d-flex align-items-center small m-2 ml-0">
104+
{icon && <Icon color="primary" src={icon} className="mr-2" size="xs" />}
105+
<span>{groupName}</span>
106+
</div>
107+
{options.map(({ displayName, value }) => (
108+
<Form.Checkbox
109+
className="m-2"
110+
key={displayName}
111+
value={value}
112+
onChange={() => handleClickCheckbox(value)}
113+
disabled={checkedBoxes.includes(value) ? false : disabled}
114+
aria-label={displayName}
115+
>
116+
<span className="small">{displayName}</span>
117+
</Form.Checkbox>
118+
))}
119+
</div>
120+
))}
121+
</Form.CheckboxSet>
122+
</Dropdown.Menu>
123+
</Dropdown>
124+
);
125+
};
126+
127+
export default MultipleChoiceFilter;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React, { useMemo } from 'react';
2+
import { Business } from '@openedx/paragon/icons';
3+
import { useOrgs } from '@src/authz-module/data/hooks';
4+
import { MultipleChoiceFilterProps } from './types';
5+
import MultipleChoiceFilter from './MultipleChoiceFilter';
6+
7+
type OrgFilterProps = Omit<MultipleChoiceFilterProps, 'filterChoices' | 'isSearchable' | 'onSearchChange'>;
8+
9+
const OrgFilter = ({
10+
filterButtonText, filterValue, setFilter, disabled,
11+
}: OrgFilterProps) => {
12+
const [searchValue, setSearchValue] = React.useState<string | undefined>(undefined);
13+
const { data: orgsData = { orgs: [] } } = useOrgs(searchValue);
14+
15+
const filterChoices = useMemo(() => orgsData.orgs.map((org) => ({
16+
displayName: org.name,
17+
value: org.id,
18+
})), [orgsData]);
19+
20+
const handleSearchChange = (value: string) => {
21+
setSearchValue(value);
22+
};
23+
24+
return (
25+
<MultipleChoiceFilter
26+
filterButtonText={filterButtonText}
27+
filterChoices={filterChoices}
28+
filterValue={filterValue}
29+
setFilter={setFilter}
30+
isSearchable
31+
onSearchChange={handleSearchChange}
32+
iconSrc={Business}
33+
disabled={disabled}
34+
/>
35+
);
36+
};
37+
38+
export default OrgFilter;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React, { useMemo } from 'react';
2+
import {
3+
Person, Language, School, LibraryBooks,
4+
} from '@openedx/paragon/icons';
5+
import MultipleChoiceFilter from './MultipleChoiceFilter';
6+
import { MultipleChoiceFilterProps } from './types';
7+
8+
type RolesFilterProps = Omit<MultipleChoiceFilterProps, 'filterChoices' | 'isSearchable' | 'onSearchChange'>;
9+
10+
const RolesFilter = ({
11+
filterButtonText, filterValue, setFilter, disabled,
12+
}: RolesFilterProps) => {
13+
// TODO: use a constant
14+
const filterChoices = useMemo(() => [
15+
{
16+
groupName: 'Global', groupIcon: Language, displayName: 'Super Admin', value: 'Super Admin',
17+
},
18+
{
19+
groupName: 'Global', groupIcon: Language, displayName: 'Global Staff', value: 'Global Staff',
20+
},
21+
22+
{
23+
groupName: 'Course', groupIcon: School, displayName: 'Course Admin', value: 'Course Admin',
24+
},
25+
{
26+
groupName: 'Course', groupIcon: School, displayName: 'Course Staff', value: 'Course Staff',
27+
},
28+
{
29+
groupName: 'Course', groupIcon: School, displayName: 'Course Editor', value: 'Course Editor',
30+
},
31+
{
32+
groupName: 'Course', groupIcon: School, displayName: 'Course Auditor', value: 'Course Auditor',
33+
},
34+
35+
{
36+
groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Admin', value: 'Library Admin',
37+
},
38+
{
39+
groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Author', value: 'Library Author',
40+
},
41+
{
42+
groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library Collaborator', value: 'Library Collaborator',
43+
},
44+
{
45+
groupName: 'Library', groupIcon: LibraryBooks, displayName: 'Library User', value: 'Library User',
46+
},
47+
], []);
48+
return (
49+
<MultipleChoiceFilter
50+
filterButtonText={filterButtonText}
51+
filterChoices={filterChoices}
52+
filterValue={filterValue}
53+
setFilter={setFilter}
54+
isGrouped
55+
iconSrc={Person}
56+
disabled={disabled}
57+
/>
58+
);
59+
};
60+
61+
export default RolesFilter;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React, { useMemo } from 'react';
2+
import { LocationOn } from '@openedx/paragon/icons';
3+
import { useScopes } from '@src/authz-module/data/hooks';
4+
import { MultipleChoiceFilterProps } from './types';
5+
import MultipleChoiceFilter from './MultipleChoiceFilter';
6+
7+
type ScopesFilterProps = Omit<MultipleChoiceFilterProps, 'filterChoices' | 'isSearchable' | 'onSearchChange'>;
8+
9+
const ScopesFilter = ({
10+
filterButtonText, filterValue, setFilter, disabled,
11+
}: ScopesFilterProps) => {
12+
const [searchValue, setSearchValue] = React.useState<string | undefined>(undefined);
13+
const { data: scopesData = { scopes: [] } } = useScopes(searchValue);
14+
15+
const filterChoices = useMemo(() => scopesData.scopes.map((scope) => ({
16+
displayName: scope.name,
17+
value: scope.key,
18+
groupName: scope.organization.name,
19+
})), [scopesData]);
20+
21+
const handleSearchChange = (value: string) => {
22+
setSearchValue(value);
23+
};
24+
25+
return (
26+
<MultipleChoiceFilter
27+
filterButtonText={filterButtonText}
28+
filterChoices={filterChoices}
29+
filterValue={filterValue}
30+
setFilter={setFilter}
31+
isSearchable
32+
isGrouped
33+
onSearchChange={handleSearchChange}
34+
iconSrc={LocationOn}
35+
disabled={disabled}
36+
/>
37+
);
38+
};
39+
40+
export default ScopesFilter;

0 commit comments

Comments
 (0)