Skip to content

Commit 3be3af0

Browse files
feat: adding filtering and sorting to audit user table
1 parent 01d7205 commit 3be3af0

17 files changed

Lines changed: 690 additions & 16 deletions

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ import baseMessages from '@src/authz-module/messages';
1212
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
1313
import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
1414
import { useUserAssignedRoles } from '@src/authz-module/data/hooks';
15-
import messages from './messages';
15+
import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar';
16+
import RolesFilter from 'authz-module/components/TableControlBar/RolesFilter';
17+
import OrgFilter from 'authz-module/components/TableControlBar/OrgFilter';
18+
import ScopesFilter from 'authz-module/components/TableControlBar/ScopesFilter';
1619
import { ViewAllPermissionsCell, ActionsCell, PermissionsCell } from './CustomCells';
20+
import messages from './messages';
21+
import { getCellHeader } from './utils';
1722

1823
const dummyData = [
1924
{
@@ -104,6 +109,7 @@ const dummyData = [
104109

105110
const AuditUserPage = () => {
106111
const { formatMessage } = useIntl();
112+
const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = React.useState<string[]>([]);
107113
const { username } = useParams();
108114
const navigate = useNavigate();
109115
const { isLoading: isLoadingUser, data: user } = useUserAccount(username ?? '');
@@ -134,17 +140,27 @@ const AuditUserPage = () => {
134140
];
135141
const columns = [
136142
{
137-
Header: formatMessage(messages['authz.user.table.role.column.header']),
143+
Header: getCellHeader('role', formatMessage(messages['authz.user.table.role.column.header']), columnsWithFiltersApplied),
138144
accessor: 'role',
145+
filter: 'includesValue',
146+
Filter: RolesFilter,
147+
filterButtonText: formatMessage(messages['authz.user.table.role.column.header']),
148+
filterOrder: 2,
139149
},
140150
{
141-
Header: formatMessage(messages['authz.user.table.organization.column.header']),
151+
Header: getCellHeader('organization', formatMessage(messages['authz.user.table.organization.column.header']), columnsWithFiltersApplied),
142152
accessor: 'organization',
153+
filter: 'includesValue',
154+
Filter: OrgFilter,
155+
filterButtonText: formatMessage(messages['authz.user.table.organization.column.header']),
156+
filterOrder: 1,
157+
143158
},
144159
{
145-
Header: formatMessage(messages['authz.user.table.scope.column.header']),
160+
Header: getCellHeader('scope', formatMessage(messages['authz.user.table.scope.column.header']), columnsWithFiltersApplied),
146161
accessor: 'scope',
147162
disableFilters: true,
163+
148164
},
149165
{
150166
Header: formatMessage(messages['authz.user.table.permissions.column.header']),
@@ -178,7 +194,11 @@ const AuditUserPage = () => {
178194
<Container className="bg-light-200 p-5">
179195
<DataTable
180196
isPaginated
197+
isFilterable
198+
isSortable
181199
manualPagination
200+
manualFilters
201+
manualSortBy
182202
data={dummyData}
183203
fetchData={fetchData}
184204
itemCount={dummyData?.length || 0}
@@ -187,6 +207,7 @@ const AuditUserPage = () => {
187207
additionalColumns={additionalColumns}
188208
columns={columns}
189209
>
210+
<TableControlBar onFilterChange={setColumnsWithFiltersApplied} />
190211
<DataTable.Table />
191212
<TableFooter />
192213
</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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
<Icon color="primary" src={FilterList} />
60+
</Stack>
61+
</Dropdown.Toggle>
62+
63+
<Dropdown.Menu>
64+
{isSearchable && (
65+
<Form.Control
66+
className="m-1"
67+
type="text"
68+
trailingElement={<Icon src={Search} />}
69+
placeholder={formatMessage(messages['authz.table.controlbar.search'])}
70+
onChange={(e) => {
71+
setSearchValue(e.target.value);
72+
onSearchChange?.(e.target.value);
73+
}}
74+
value={searchValue}
75+
/>
76+
)}
77+
<Form.CheckboxSet
78+
className="pgn__dropdown-filter-checkbox-group"
79+
name={filterButtonText}
80+
aria-label={filterButtonText}
81+
value={checkedBoxes}
82+
>
83+
{/** TODO: Change for actual values */}
84+
<span className="small text-info-700 mt-2">{formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })}</span>
85+
{!isGrouped ? filterChoices.map(({
86+
displayName, value,
87+
}) => (
88+
<Form.Checkbox
89+
className="m-2"
90+
key={displayName}
91+
checked={checkedBoxes.includes(value)}
92+
value={value}
93+
onChange={() => handleClickCheckbox(value)}
94+
aria-label={displayName}
95+
disabled={checkedBoxes.includes(value) ? false : disabled}
96+
>
97+
<span className="small">{displayName}</span>
98+
</Form.Checkbox>
99+
))
100+
: getGroupedChoices().map(({ groupName, icon, options }) => (
101+
<div key={groupName}>
102+
<div className="pgn__dropdown-filter-group-name text-info-700 d-flex align-items-center small m-2 ml-0">
103+
{icon && <Icon color="primary" src={icon} className="mr-2" size="xs" />}
104+
<span>{groupName}</span>
105+
</div>
106+
{options.map(({ displayName, value }) => (
107+
<Form.Checkbox
108+
className="m-2"
109+
key={displayName}
110+
value={value}
111+
onChange={() => handleClickCheckbox(value)}
112+
disabled={checkedBoxes.includes(value) ? false : disabled}
113+
aria-label={displayName}
114+
>
115+
<span className="small">{displayName}</span>
116+
</Form.Checkbox>
117+
))}
118+
</div>
119+
))}
120+
</Form.CheckboxSet>
121+
</Dropdown.Menu>
122+
</Dropdown>
123+
);
124+
};
125+
126+
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)