From a7ae914f4fe4c6926795fcda7e2e19e5de86a952 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Thu, 15 Aug 2024 16:43:29 -0700 Subject: [PATCH] fix(UsersManager): fix search and pagination --- client/src/Conductor.jsx | 2 +- client/src/api.ts | 15 + .../components/controlpanel/UsersManager.tsx | 471 ------------------ .../controlpanel/UsersManager/index.tsx | 262 ++++++++++ server/api.js | 2 + server/api/users.js | 76 +-- server/api/validators/user.ts | 9 + 7 files changed, 336 insertions(+), 501 deletions(-) delete mode 100644 client/src/components/controlpanel/UsersManager.tsx create mode 100644 client/src/screens/conductor/controlpanel/UsersManager/index.tsx create mode 100644 server/api/validators/user.ts diff --git a/client/src/Conductor.jsx b/client/src/Conductor.jsx index 9d66d8f3..2c01f69f 100644 --- a/client/src/Conductor.jsx +++ b/client/src/Conductor.jsx @@ -47,7 +47,7 @@ import ProjectTimeline from './components/projects/ProjectTimeline'; import ProjectView from './components/projects/ProjectView'; const Search = lazy(() => import('./screens/conductor/Search')); import UserDetails from './components/controlpanel/UserDetails'; -import UsersManager from './components/controlpanel/UsersManager'; +const UsersManager = lazy(() => import('./screens/conductor/controlpanel/UsersManager')); import LoadingSpinner from './components/LoadingSpinner'; const CentralIdentity = lazy(() => import('./screens/conductor/controlpanel/CentralIdentity')); const CentralIdentityInstructorVerifications = lazy(() => import('./screens/conductor/controlpanel/CentralIdentity/CentralIdentityInstructorVerifications')); diff --git a/client/src/api.ts b/client/src/api.ts index e8dfbc68..79dda9b5 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -811,6 +811,21 @@ class API { async deleteCollectionResource(collID: string, resourceID: string) { return await axios.delete(`/commons/collection/${collID}/resources/${resourceID}`); } + + // USERS (Control Panel) + async getUsers(params: { + query?: string; + page?: number; + limit?: number; + sort?: string; + }) { + return await axios.get<{ + results: User[]; + total_items: number; + } & ConductorBaseResponse>("/users", { + params + }); + } } export default new API(); diff --git a/client/src/components/controlpanel/UsersManager.tsx b/client/src/components/controlpanel/UsersManager.tsx deleted file mode 100644 index ea606baf..00000000 --- a/client/src/components/controlpanel/UsersManager.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import './ControlPanel.css'; - -import { - Grid, - Header, - Segment, - Table, - Modal, - Button, - Dropdown, - Icon, - Pagination, - Input, - Breadcrumb, - List -} from 'semantic-ui-react'; -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { useTypedSelector } from '../../state/hooks'; -import axios from 'axios'; - -import { itemsPerPageOptions } from '../util/PaginationOptions.js'; -import useGlobalError from '../error/ErrorHooks'; -import { User } from '../../types'; -import ManageUserRolesModal from './UsersManager/ManageUserRolesModal'; - -const UsersManager = () => { - - // Global State - const { handleGlobalError } = useGlobalError(); - const org = useTypedSelector((state) => state.org); - const isSuperAdmin = useTypedSelector((state) => state.user.isSuperAdmin); - const isCampusAdmin = useTypedSelector((state) => state.user.isCampusAdmin); - - // Data - const [allUsers, setAllUsers] = useState([]); - const [displayUsers, setDisplayUsers] = useState([]); - const [pageUsers, setPageUsers] = useState([]); - - // UI - const [activePage, setActivePage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(10); - const [loadedData, setLoadedData] = useState(false); - - const [searchString, setSearchString] = useState(''); - const [sortChoice, setSortChoice] = useState('first'); - - // Manage Roles Modal - const [showManageRolesModal, setShowManageRolesModal] = useState(false); - const [manageRolesUUID, setManageRolesUUID] = useState(''); - const [manageRolesFirstName, setManageRolesFirstName] = useState(''); - const [manageRolesLastName, setManageRolesLastName] = useState(''); - - // Delete User Modal - const [showDelUserModal, setShowDelUserModal] = useState(false); - const [delUserUUID, setDelUserUUID] = useState(''); - const [delUserName, setDelUserName] = useState(''); - const [delUserLoading, setDelUserLoading] = useState(false); - - const sortOptions = [ - { key: 'first', text: 'Sort by First Name', value: 'first' }, - { key: 'last', text: 'Sort by Last Name', value: 'last' }, - { key: 'email', text: 'Sort by Email', value: 'email' }, - ]; - - - /** - * Set page title and retrieve - * users on initial load. - */ - useEffect(() => { - document.title = "LibreTexts Conductor | Users Manager"; - getUsers(); - }, []); - - - /** - * Track changes to the number of users loaded - * and the selected itemsPerPage and update the - * set of users to display. - */ - useEffect(() => { - setTotalPages(Math.ceil(displayUsers.length/itemsPerPage)); - setPageUsers(displayUsers.slice((activePage - 1) * itemsPerPage, activePage * itemsPerPage)); - }, [itemsPerPage, displayUsers, activePage]); - - - /** - * Refilter whenever the sort option - * or the search string changes. - */ - useEffect(() => { - filterAndSortUsers(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allUsers, searchString, sortChoice]); - - - /** - * Retrieve the list of users from - * the server. - */ - const getUsers = () => { - setLoadedData(false); - if (isSuperAdmin || isCampusAdmin) { - axios.get('/users').then((res) => { - if (!res.data.err) { - if (res.data.users && Array.isArray(res.data.users)) { - setAllUsers(res.data.users); - } - } else { - handleGlobalError(res.data.errMsg); - } - setLoadedData(true); - }).catch((err) => { - handleGlobalError(err); - setLoadedData(true); - }); - } - }; - - - /** - * Filter and sort users according - * to current filters and sort - * choice. - */ - const filterAndSortUsers = () => { - setLoadedData(false); - let filtered = allUsers.filter((user) => { - var include = true; - var descripString = String(user.firstName).toLowerCase() + String(user.lastName).toLowerCase() - + String(user.email).toLowerCase() + String(user.authType).toLowerCase(); - if (searchString !== '' && String(descripString).indexOf(String(searchString).toLowerCase()) === -1) { - include = false; - } - if (include) { - return user; - } else { - return false; - } - }); - if (sortChoice === 'first') { - const sorted = [...filtered].sort((a, b) => { - var normalA = String(a.firstName).toLowerCase().replace(/[^A-Za-z]+/g, ""); - var normalB = String(b.firstName).toLowerCase().replace(/[^A-Za-z]+/g, ""); - if (normalA < normalB) { - return -1; - } - if (normalA > normalB) { - return 1; - } - return 0; - }); - setDisplayUsers(sorted); - } else if (sortChoice === 'last') { - const sorted = [...filtered].sort((a, b) => { - var normalA = String(a.lastName).toLowerCase().replace(/[^A-Za-z]+/g, ""); - var normalB = String(b.lastName).toLowerCase().replace(/[^A-Za-z]+/g, ""); - if (normalA < normalB) { - return -1; - } - if (normalA > normalB) { - return 1; - } - return 0; - }); - setDisplayUsers(sorted); - } else if (sortChoice === 'email') { - const sorted = [...filtered].sort((a, b) => { - var normalA = String(a.email).toLowerCase().replace(/[^A-Za-z]+/g, ""); - var normalB = String(b.email).toLowerCase().replace(/[^A-Za-z]+/g, ""); - if (normalA < normalB) { - return -1; - } - if (normalA > normalB) { - return 1; - } - return 0; - }); - setDisplayUsers(sorted); - } else if (sortChoice === 'auth') { - const sorted = [...filtered].sort((a, b) => { - var normalA = String(a.authType).toLowerCase().replace(/[^A-Za-z]+/g, ""); - var normalB = String(b.authType).toLowerCase().replace(/[^A-Za-z]+/g, ""); - if (normalA < normalB) { - return -1; - } - if (normalA > normalB) { - return 1; - } - return 0; - }); - setDisplayUsers(sorted); - } - setLoadedData(true); - }; - - - /** - * Open the Manage User Roles Modal and - * set its respective values to the - * requested user. - */ - const openManageUserModal = (uuid: string, firstName: string, lastName: string) => { - if ((uuid !== '') && (firstName !== '') && (lastName !== '')) { - setManageRolesUUID(uuid); - setManageRolesFirstName(firstName); - setManageRolesLastName(lastName); - setShowManageRolesModal(true); - } - }; - - - /** - * Close the Manage User Roles Modal and - * reset its values to their defaults. - */ - const closeManageRolesModal = () => { - setShowManageRolesModal(false); - setManageRolesUUID(''); - setManageRolesFirstName(''); - setManageRolesLastName(''); - }; - - - /** - * Submit a PUT request to the server - * to delete the user currently - * being modified in the Delete - * User Modal (delUserUUID). - */ - const submitDeleteUser = () => { - setDelUserLoading(true); - axios.put('/user/delete', { - uuid: delUserUUID - }).then((res) => { - if (!res.data.err) { - closeDelUserModal(); - getUsers(); - } else { - handleGlobalError(res.data.errMsg); - } - setDelUserLoading(false); - }).catch((err) => { - handleGlobalError(err); - setDelUserLoading(false); - }); - }; - - - /** - * Open the Delete User Modal and - * set its respective values to the - * requested user. - */ - const openDelUserModal = (uuid: string, firstName: string, lastName: string) => { - if ((uuid !== '') && (firstName !== '') && (lastName !== '')) { - setDelUserUUID(uuid); - setDelUserName(firstName + ' ' + lastName); - setDelUserLoading(false); - setShowDelUserModal(true); - } - }; - - - /** - * Close the Delete User Modal and - * reset its values to their defaults. - */ - const closeDelUserModal = () => { - setShowDelUserModal(false); - setDelUserUUID(''); - setDelUserName(''); - setDelUserLoading(false); - }; - - - return ( - - - -
Users Manager
-
-
- - - - - - - Control Panel - - - - Users Manager - - - - - - - - { setSortChoice(value as string) }} - value={sortChoice} - /> - - - { setSearchString(e.target.value) }} - value={searchString} - fluid - /> - - - - - -
-
- Displaying - { - setItemsPerPage(value as number); - }} - value={itemsPerPage} - /> - items per page of {Number(allUsers.length).toLocaleString()} results. -
-
- { - setActivePage(data.activePage as number) - }} - /> -
-
-
- - - - - - {(sortChoice === 'first') - ? First Name - : First Name - } - - - {(sortChoice === 'last') - ? Last Name - : Last Name - } - - - {(sortChoice === 'email') - ? Email - : Email - } - - - Actions - - - - - {(pageUsers.length > 0) && - pageUsers.map((item, index) => { - return ( - - -

{item.firstName}

-
- -

{item.lastName}

-
- -

{item.email}

-
- - - - - - -
- ) - }) - } - {(pageUsers.length === 0) && - - -

No results found.

-
-
- } -
-
-
-
- - {/* Delete User Modal */} - - Delete User - -

CAUTION: Are you sure you want to delete user {delUserName} ({delUserUUID})?

-

Note: this will not prevent the user from registering again in the future.

-
- - - - -
-
-
-
- ) - -} - -export default UsersManager; diff --git a/client/src/screens/conductor/controlpanel/UsersManager/index.tsx b/client/src/screens/conductor/controlpanel/UsersManager/index.tsx new file mode 100644 index 00000000..35339ff2 --- /dev/null +++ b/client/src/screens/conductor/controlpanel/UsersManager/index.tsx @@ -0,0 +1,262 @@ +import '../../../../components/controlpanel/ControlPanel.css'; + +import { + Grid, + Header, + Segment, + Table, + Button, + Dropdown, + Icon, + Input, + Breadcrumb, +} from 'semantic-ui-react'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTypedSelector } from '../../../../state/hooks.js'; + +import { itemsPerPageOptions } from '../../../../components/util/PaginationOptions.js'; +import useGlobalError from '../../../../components/error/ErrorHooks.js'; +import { User } from '../../../../types'; +import ManageUserRolesModal from '../../../../components/controlpanel/UsersManager/ManageUserRolesModal.js'; +import { useQuery } from '@tanstack/react-query'; +import api from '../../../../api'; +import ConductorPagination from '../../../../components/util/ConductorPagination'; +import useDebounce from '../../../../hooks/useDebounce'; +import { useModals } from '../../../../context/ModalContext'; + +const UsersManager = () => { + + // Global State & Hooks + const { handleGlobalError } = useGlobalError(); + const org = useTypedSelector((state) => state.org); + const isSuperAdmin = useTypedSelector((state) => state.user.isSuperAdmin); + const { debounce } = useDebounce(); + const { openModal, closeAllModals } = useModals(); + + // UI + const [page, setPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + const [sortChoice, setSortChoice] = useState('first'); + const [searchString, setSearchString] = useState(''); // for debounce, this is the value that will be used + const [searchInput, setSearchInput] = useState(''); // for debounce, this is the input value + + const { data, isFetching: loading } = useQuery({ + queryKey: ['users', searchString, sortChoice, page, itemsPerPage], + queryFn: () => getUsers({ query: searchString, limit: itemsPerPage, page, sort: sortChoice }), + refetchOnWindowFocus: false, + }) + + const sortOptions = [ + { key: 'first', text: 'Sort by First Name', value: 'first' }, + { key: 'last', text: 'Sort by Last Name', value: 'last' }, + { key: 'email', text: 'Sort by Email', value: 'email' }, + ]; + + + useEffect(() => { + document.title = "LibreTexts Conductor | Users Manager"; + }, []); + + async function getUsers({ query, limit, page, sort }: { query?: string, limit?: number, page?: number, sort?: string }): Promise<{ + results: User[], + total_items: number + }> { + try { + const res = await api.getUsers({ query, limit, page, sort }) + if (res.data.err) { + throw new Error(res.data.errMsg) + } + + return { + results: res.data.results, + total_items: res.data.total_items + } + } catch (err) { + handleGlobalError(err); + return { + results: [], + total_items: 0 + } + } + } + + const debouncedSearch = debounce((newVal: string) => { + setSearchString(newVal); + }, 200); + + const openManageUserModal = (uuid: string, firstName: string, lastName: string) => { + if (!uuid) return; + + openModal( + + ) + }; + + return ( + + + +
Users Manager
+
+
+ + + + + + + Control Panel + + + + Users Manager + + + + + + + + { setSortChoice(value as string) }} + value={sortChoice} + /> + + + { + setSearchInput(e.target.value); + debouncedSearch(e.target.value); + }} + value={searchInput} + fluid + /> + + + + + +
+
+ Displaying + { + setItemsPerPage(value as number); + }} + value={itemsPerPage} + /> + items per page of {Number(data?.total_items || 0).toLocaleString()} results. +
+
+ 0 ? Math.ceil(data?.total_items / itemsPerPage) : 1} + onPageChange={(e, { activePage }) => { + setPage(activePage as number); + }} + /> +
+
+
+ + + + + + {(sortChoice === 'first') + ? First Name + : First Name + } + + + {(sortChoice === 'last') + ? Last Name + : Last Name + } + + + {(sortChoice === 'email') + ? Email + : Email + } + + + Actions + + + + + {(data && data.results.length > 0) && + data.results.map((item, index) => { + return ( + + +

{item.firstName}

+
+ +

{item.lastName}

+
+ +

{item.email}

+
+ + + + + + +
+ ) + }) + } + {(!data || data.results.length === 0) && + + +

No results found.

+
+
+ } +
+
+
+
+
+
+
+ ) + +} + +export default UsersManager; diff --git a/server/api.js b/server/api.js index 4d843189..e246971c 100644 --- a/server/api.js +++ b/server/api.js @@ -47,6 +47,7 @@ import * as SearchValidators from './api/validators/search.js'; import * as AssetTagFrameworkValidators from './api/validators/assettagframeworks.js'; import * as AuthorsValidators from './api/validators/authors.js'; import * as BookValidators from './api/validators/book.js'; +import * as UserValidators from './api/validators/user.js'; const router = express.Router(); @@ -804,6 +805,7 @@ router.route('/users').get( authAPI.verifyRequest, authAPI.getUserAttributes, authAPI.checkHasRoleMiddleware(process.env.ORG_ID, 'campusadmin'), + middleware.validateZod(UserValidators.GetUsersSchema), usersAPI.getUsersList, ); diff --git a/server/api/users.js b/server/api/users.js index d89ccc5e..a9e24b6d 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -9,6 +9,7 @@ import { debugError } from '../debug.js'; import User from '../models/user.js'; import conductorErrors from '../conductor-errors.js'; import authAPI from './auth.js'; +import { getPaginationOffset } from '../util/helpers.js'; /** * Return basic profile information about the current user. @@ -211,43 +212,60 @@ async function updateUserInstructorProfile(req, res) { * @param {Object} req - The Express.js request object. * @param {Object} res - The Express.js response object. */ -const getUsersList = (_req, res) => { - User.aggregate([ - { - $match: { - $expr: { $not: '$isSystem' }, - }, - }, { - $project: { - _id: 0, - uuid: 1, - firstName: 1, - lastName: 1, - email: 1, - authType: 1 +const getUsersList = async (_req, res) => { + try { + const query = _req.query.query; + const page = parseInt(_req.query.page.toString()) || 1; + const limit = parseInt(_req.query.limit.toString()) || 10; + const sort = _req.query.sort || 'first'; + + const queryObj = { + $search: { + text: { + query, + path: ['firstName', 'lastName', 'email'], + } } } - ]).then((users) => { - let processedUsers = users.map((user) => { - if (user.authType !== null) { - if (user.authType === 'traditional') user.authType = 'Traditional'; - else if (user.authType === 'sso') user.authType = 'SSO'; - } else { - user.authType = 'Traditional'; + + const data = await User.aggregate([ + ...(query && query.length > 0 ? [queryObj] : []), + { + $match: { + $expr: { $not: '$isSystem' }, + }, + }, + { + $project: { + _id: 0, + uuid: 1, + firstName: 1, + lastName: 1, + email: 1, + authType: 1 + } + }, + { + $sort: { + [sort === 'first' ? 'firstName' : sort === 'last' ? 'lastName' : 'email']: 1 + } } - return user; - }); + ]); + + const offset = getPaginationOffset(page, limit); + return res.send({ - err: false, - users: processedUsers - }); - }).catch((err) => { - debugError(err); + results: data.slice(offset, offset + limit), + total_items: data.length, + }) + + } catch (e){ + debugError(e); return res.send({ err: true, errMsg: conductorErrors.err6 }); - }); + } }; diff --git a/server/api/validators/user.ts b/server/api/validators/user.ts new file mode 100644 index 00000000..0343e79d --- /dev/null +++ b/server/api/validators/user.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { PaginationSchema } from "./misc.js"; + +export const GetUsersSchema = z.object({ + query: z.object({ + query: z.string().min(1).max(50).or(z.literal("")).optional(), + sort: z.enum(['first', 'last', 'email']).optional().default('first'), + }).merge(PaginationSchema) +}) \ No newline at end of file