From 7abe9edb7d6948bb354d74658f2f0d53267a235f Mon Sep 17 00:00:00 2001 From: Ali-D-Akbar Date: Thu, 26 Aug 2021 18:59:57 +0500 Subject: [PATCH] feat: add enrollmentv2 --- jest.config.js | 1 + package-lock.json | 20 ++ package.json | 2 + src/components/Table.jsx | 100 +++++++ src/index.jsx | 2 + src/overrides.scss | 38 ++- src/users/enrollments/v2/Enrollments.jsx | 264 ++++++++++++++++++ src/users/enrollments/v2/Enrollments.test.jsx | 146 ++++++++++ src/users/v2/UserPage.jsx | 192 +++++++++++++ 9 files changed, 764 insertions(+), 1 deletion(-) create mode 100644 src/components/Table.jsx create mode 100644 src/users/enrollments/v2/Enrollments.jsx create mode 100644 src/users/enrollments/v2/Enrollments.test.jsx create mode 100644 src/users/v2/UserPage.jsx diff --git a/jest.config.js b/jest.config.js index e9c25bca6..9f34d8b8b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,5 +8,6 @@ module.exports = createConfig('jest', { '/node_modules/', 'src/setupTest.js', 'src/i18n', + 'src/users/v2/UserPage.jsx' ], }); diff --git a/package-lock.json b/package-lock.json index 0434c5e05..b5b4735fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,12 +28,14 @@ "react-responsive": "8.1.0", "react-router": "5.2.0", "react-router-dom": "5.2.0", + "react-table": "^7.6.3", "react-transition-group": "4.4.1", "redux": "4.0.5" }, "devDependencies": { "@edx/frontend-build": "^5.5.1", "@testing-library/react": "10.3.0", + "@types/react-table": "^7.7.2", "axios-mock-adapter": "^1.19.0", "check-prop-types": "1.1.2", "codecov": "3.8.1", @@ -4203,6 +4205,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-table": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.2.tgz", + "integrity": "sha512-NwB78t3YV5pZ1NK3m2vylb/d0DKVyWH4y4GMCtlE4tg2n5ENM4ejzKnT46YKuqG2cPjWc+PIxuRVMd5OYX1z4A==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz", @@ -30603,6 +30614,15 @@ "csstype": "^3.0.2" } }, + "@types/react-table": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.2.tgz", + "integrity": "sha512-NwB78t3YV5pZ1NK3m2vylb/d0DKVyWH4y4GMCtlE4tg2n5ENM4ejzKnT46YKuqG2cPjWc+PIxuRVMd5OYX1z4A==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-transition-group": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz", diff --git a/package.json b/package.json index 3d3376bf2..d6ced25a5 100644 --- a/package.json +++ b/package.json @@ -49,12 +49,14 @@ "react-responsive": "8.1.0", "react-router": "5.2.0", "react-router-dom": "5.2.0", + "react-table": "^7.6.3", "react-transition-group": "4.4.1", "redux": "4.0.5" }, "devDependencies": { "@edx/frontend-build": "^5.5.1", "@testing-library/react": "10.3.0", + "@types/react-table": "^7.7.2", "axios-mock-adapter": "^1.19.0", "check-prop-types": "1.1.2", "codecov": "3.8.1", diff --git a/src/components/Table.jsx b/src/components/Table.jsx new file mode 100644 index 000000000..88a3a46b0 --- /dev/null +++ b/src/components/Table.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { useTable, useExpanded, useSortBy } from 'react-table'; +import PropTypes from 'prop-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons'; + +export default function Table({ + columns, data, renderRowSubComponent, styleName, +}) { + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + visibleColumns, + } = useTable( + { + columns, + data, + }, + useSortBy, + useExpanded, // Using useExpanded to track the expanded state + ); + + return ( + <> + + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => ( + + ))} + + ))} + + + {rows.map((row) => { + prepareRow(row); + return ( + + + {row.cells.map(cell => ( + + ))} + + {/* + If the row is in an expanded state, render a row with a + column that fills the entire length of the table. + */} + {row.isExpanded ? ( + + + + ) : null} + + ); + })} + +
+ {column.render('Header')} + + {/* eslint-disable-next-line no-nested-ternary */} + {column.isSorted + ? column.isSortedDesc + ? + : + : ''} + +
{cell.render('Cell')}
+ {/* + Inside it, call our renderRowSubComponent function. In reality, + you could pass whatever you want as props to + a component like this, including the entire + table instance. + it's merely a rendering option we created for ourselves + */} + {renderRowSubComponent({ row })} +
+
+ + ); +} + +Table.propTypes = { + columns: PropTypes.arrayOf(PropTypes.shape({ + header: PropTypes.string.isRequired, + accessor: PropTypes.string.isRequired, + sortable: PropTypes.bool, + })).isRequired, + data: PropTypes.arrayOf(PropTypes.object).isRequired, + renderRowSubComponent: PropTypes.func, + styleName: PropTypes.string, +}; + +Table.defaultProps = { + renderRowSubComponent: null, + styleName: null, +}; diff --git a/src/index.jsx b/src/index.jsx index 9a27faf38..d96c668d1 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -14,6 +14,7 @@ import SupportHomePage from './supportHome/SupportHomePage'; import Header from './supportHeader'; import appMessages from './i18n'; import UserPage from './users/UserPage'; +// import UserPageV2 from './users/v2/UserPage'; import UserMessagesProvider from './userMessages/UserMessagesProvider'; import './index.scss'; @@ -35,6 +36,7 @@ subscribe(APP_READY, () => { + {/* */} , diff --git a/src/overrides.scss b/src/overrides.scss index b8f69ce9e..db67d320f 100644 --- a/src/overrides.scss +++ b/src/overrides.scss @@ -1,6 +1,42 @@ // Reverting the container width to 1440px to keep the support tools styling consistent // See https://github.com/edx/paragon/pull/533 for paragon breaking change + .container-fluid { -max-width: 1440px; + max-width: 1440px; +} + +.custom-table { + font-size: small; + width: 100%; + th { + margin: 0; + padding: 0.5rem; + border-bottom: 1px solid black; + } + td { + margin: 0; + padding: 0.5rem; + border-bottom: 1px solid silver; + } +} + +.custom-expander-table { + font-size: small; + margin-left: 10%; + th { + margin: 0; + padding: 0.5rem; + background-color: lightgray; + border-bottom: 0px; + } + td { + margin: 0; + padding: 0.5rem; + border-bottom: 0px; + } +} + +.text-center { + margin: auto; } diff --git a/src/users/enrollments/v2/Enrollments.jsx b/src/users/enrollments/v2/Enrollments.jsx new file mode 100644 index 000000000..16164fc50 --- /dev/null +++ b/src/users/enrollments/v2/Enrollments.jsx @@ -0,0 +1,264 @@ +import React, { + useMemo, + useState, + useCallback, + useRef, + useLayoutEffect, + useEffect, + useContext, +} from 'react'; + +import { + Button, TransitionReplace, Dropdown, +} from '@edx/paragon'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import PropTypes from 'prop-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'; +import Certificates from '../Certificates'; +import EnrollmentForm from '../EnrollmentForm'; +import { CREATE, CHANGE } from '../constants'; +import PageLoading from '../../../components/common/PageLoading'; +import UserMessagesContext from '../../../userMessages/UserMessagesContext'; +import TableV2 from '../../../components/Table'; +import { formatDate } from '../../../utils'; +import { getEnrollments } from '../../data/api'; +import AlertList from '../../../userMessages/AlertList'; + +export default function Enrollments({ + changeHandler, user, +}) { + const { add, clear } = useContext(UserMessagesContext); + const [formType, setFormType] = useState(null); + const [enrollmentData, setEnrollmentData] = useState(null); + const [enrollmentToChange, setEnrollmentToChange] = useState(undefined); + const [selectedCourseId, setSelectedCourseId] = useState(undefined); + const formRef = useRef(null); + + useEffect(() => { + clear('enrollments'); + getEnrollments(user).then((result) => { + const camelCaseResult = camelCaseObject(result); + if (camelCaseResult.errors) { + camelCaseResult.errors.forEach(error => add(error)); + setEnrollmentData([]); + } else { + setEnrollmentData(camelCaseResult); + } + }); + }, [user]); + + const tableData = useMemo(() => { + if (enrollmentData === null || enrollmentData.length === 0) { + return []; + } + return enrollmentData.map(enrollment => ({ + expander: { + lastModified: enrollment.manualEnrollment ? formatDate(enrollment.manualEnrollment.timeStamp) : 'N/A', + lastModifiedBy: enrollment.manualEnrollment && enrollment.manualEnrollment.enrolledBy ? enrollment.manualEnrollment.enrolledBy : 'N/A', + reason: enrollment.manualEnrollment && enrollment.manualEnrollment.reason ? enrollment.manualEnrollment.reason : 'N/A', + }, + courseId: {enrollment.courseId}, + courseName: enrollment.courseName, + courseStart: formatDate(enrollment.courseStart), + courseEnd: formatDate(enrollment.courseEnd), + upgradeDeadline: formatDate(enrollment.verifiedUpgradeDeadline), + created: formatDate(enrollment.created), + pacingType: enrollment.pacingType, + active: enrollment.isActive ? 'True' : 'False', + mode: enrollment.mode, + actions: ( + + + Actions + + + { + setEnrollmentToChange(enrollment); + setFormType(CHANGE); + }} + className="small" + > + Change Enrollment + + { + setSelectedCourseId(enrollment.courseId); + }} + className="small" + > + View Certificate + + + + ), + })); + }, [enrollmentData]); + + useLayoutEffect(() => { + if (formType != null) { + formRef.current.focus(); + } + }); + + const expandAllRowsHandler = ({ getToggleAllRowsExpandedProps }) => ( + + Expand All + + ); + expandAllRowsHandler.propTypes = { + getToggleAllRowsExpandedProps: PropTypes.func.isRequired, + }; + + const rowExpandHandler = ({ row }) => ( + // We can use the getToggleRowExpandedProps prop-getter + // to build the expander. +
+ + {row.isExpanded ? ( + + ) : } + +
+ ); + + rowExpandHandler.propTypes = { + row: PropTypes.shape({ + isExpanded: PropTypes.bool, + getToggleRowExpandedProps: PropTypes.func, + }).isRequired, + }; + + const columns = React.useMemo( + () => [ + { + // Make an expander column + Header: expandAllRowsHandler, + id: 'expander', + Cell: rowExpandHandler, // Use Cell to render an expander for each row. + }, + { + Header: 'Course Run ID', accessor: 'courseId', sortable: true, + }, + { + Header: 'Course Title', accessor: 'courseName', sortable: true, + }, + { + Header: 'Course Start', accessor: 'courseStart', sortable: true, + }, + { + Header: 'Course End', accessor: 'courseEnd', sortable: true, + }, + { + Header: 'Upgrade Deadline', accessor: 'upgradeDeadline', sortable: true, + }, + { + Header: 'Enrollment Date', accessor: 'created', sortable: true, + }, + { + Header: 'Pacing Type', accessor: 'pacingType', sortable: true, + }, + { + Header: 'Mode', accessor: 'mode', sortable: true, + }, + { + Header: 'Active', accessor: 'active', sortable: true, + }, + { + Header: 'Actions', accessor: 'actions', + }, + ], + [], + ); + + const extraColumns = React.useMemo( + () => [ + { + Header: 'Last Modified', accessor: 'lastModified', + }, + { + Header: 'Last Modified By', accessor: 'lastModifiedBy', + }, + { + Header: 'Reason', accessor: 'reason', + }, + ], + [], + ); + + const renderRowSubComponent = useCallback( + ({ row }) => ( + + ), + [], + ); + + return ( +
+ {!formType && ( +
+

Enrollments ({tableData.length})

+ +
+ )} + {enrollmentData + ? ( + + ) + : } + + + + {formType != null ? ( + {}} + changeHandler={changeHandler} + closeHandler={() => setFormType(null)} + forwardedRef={formRef} + /> + ) : () } + + + {selectedCourseId !== undefined ? ( + setSelectedCourseId(undefined)} + courseId={selectedCourseId} + username={user} + /> + ) : () } + +
+ ); +} + +Enrollments.propTypes = { + changeHandler: PropTypes.func.isRequired, + user: PropTypes.string.isRequired, +}; diff --git a/src/users/enrollments/v2/Enrollments.test.jsx b/src/users/enrollments/v2/Enrollments.test.jsx new file mode 100644 index 000000000..a00fef69b --- /dev/null +++ b/src/users/enrollments/v2/Enrollments.test.jsx @@ -0,0 +1,146 @@ +import { mount } from 'enzyme'; +import React from 'react'; +import EnrollmentsV2 from './Enrollments'; +import { enrollmentsData } from '../../data/test/enrollments'; +import UserMessagesProvider from '../../../userMessages/UserMessagesProvider'; +import { waitForComponentToPaint } from '../../../setupTest'; +import * as api from '../../data/api'; + +const EnrollmentPageWrapper = (props) => ( + + + +); + +describe('Course Enrollments V2 Listing', () => { + let wrapper; + const props = { + user: 'edX', + changeHandler: jest.fn(() => {}), + }; + + beforeEach(async () => { + jest.spyOn(api, 'getEnrollments').mockImplementationOnce(() => Promise.resolve(enrollmentsData)); + wrapper = mount(); + await waitForComponentToPaint(wrapper); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it('default enrollment data', () => { + const componentHeader = wrapper.find('h3'); + expect(componentHeader.text()).toEqual('Enrollments (2)'); + }); + + it('No Enrollment Data', async () => { + jest.spyOn(api, 'getEnrollments').mockImplementationOnce(() => Promise.resolve([])); + wrapper = mount(); + await waitForComponentToPaint(wrapper); + const componentHeader = wrapper.find('h3'); + expect(componentHeader.text()).toEqual('Enrollments (0)'); + }); + + it('Error fetching enrollments', async () => { + const enrollmentErrors = { + errors: [ + { + code: null, + dismissible: true, + text: 'An unexpected error occurred. Please try refreshing the page.', + type: 'danger', + topic: 'enrollments', + }, + ], + }; + jest.spyOn(api, 'getEnrollments').mockImplementationOnce(() => Promise.resolve(enrollmentErrors)); + wrapper = mount(); + await waitForComponentToPaint(wrapper); + + const alert = wrapper.find('div.alert'); + expect(alert.text()).toEqual(enrollmentErrors.errors[0].text); + }); + + it('Enrollment create form is rendered', () => { + const createEnrollmentButton = wrapper.find('button#create-enrollment-button'); + createEnrollmentButton.simulate('click'); + const createEnrollmentForm = wrapper.find('CreateEnrollmentForm'); + expect(createEnrollmentForm.find('h4').text()).toEqual(expect.stringContaining('Create New Enrollment')); + createEnrollmentForm.find('button.btn-outline-secondary').simulate('click'); + expect(wrapper.find('CreateEnrollmentForm')).toEqual({}); + }); + + it('Enrollment change form is rendered for individual enrollment', () => { + let dataRow = wrapper.find('table tbody tr').at(0); + const courseId = dataRow.find('td').at(1).text(); + dataRow.find('.dropdown button').simulate('click'); + dataRow = wrapper.find('table tbody tr').at(0); + dataRow.find('.dropdown-menu.show a').at(0).simulate('click'); + + const changeEnrollmentForm = wrapper.find('ChangeEnrollmentForm'); + expect(changeEnrollmentForm.html()).toEqual(expect.stringContaining(courseId)); + changeEnrollmentForm.find('button.btn-outline-secondary').simulate('click'); + expect(wrapper.find('changeEnrollmentForm')).toEqual({}); + }); + + it('Enrollment extra data is rendered for individual enrollment', () => { + let expandable = wrapper.find('table tbody tr').at(0).find('td div span').at(0); + expect(expandable.html()).toContain('fa-plus'); + expandable.simulate('click'); + + expandable = wrapper.find('table tbody tr').at(0).find('td div span').at(0); + expect(expandable.html()).toContain('fa-minus'); + + const extraTable = wrapper.find('table tbody tr').at(1).find('table'); + const extraTableHeaders = extraTable.find('thead tr th'); + expect(extraTable.find('thead tr th').length).toEqual(3); + expect(extraTableHeaders.at(0).text()).toEqual('Last Modified'); + expect(extraTableHeaders.at(1).text()).toEqual('Last Modified By'); + expect(extraTableHeaders.at(2).text()).toEqual('Reason'); + + expandable.simulate('click'); + + expandable = wrapper.find('table tbody tr').at(0).find('td div span').at(0); + expect(expandable.html()).toContain('fa-plus'); + }); + + it('Expand all button shows extra data for all enrollments', () => { + let expandable = wrapper.find('table tbody tr').at(0).find('td div span'); + expect(expandable.at(0).html()).toContain('fa-plus'); + expect(expandable.at(0).html()).toContain('fa-plus'); + const expandAll = wrapper.find('table thead tr th a').at(0); + expandAll.simulate('click'); + + expandable = wrapper.find('table tbody tr').at(0).find('td div span'); + expect(expandable.at(0).html()).toContain('fa-minus'); + expect(expandable.at(0).html()).toContain('fa-minus'); + expandAll.simulate('click'); + + expandable = wrapper.find('table tbody tr').at(0).find('td div span'); + expect(expandable.at(0).html()).toContain('fa-plus'); + expect(expandable.at(0).html()).toContain('fa-plus'); + }); + + it('View Certificate action', async () => { + /** + * Testing the certificate fetch on first row only. Async painting in the loop was causing + * the test to pass data across the loop, causing inconsistent behavior.. + */ + let dataRow = wrapper.find('table tbody tr').at(0); + const courseName = dataRow.find('td').at(2).text(); + const apiMock = jest.spyOn(api, 'getCertificate').mockImplementationOnce(() => Promise.resolve({ courseKey: courseName })); + dataRow.find('.dropdown button').simulate('click'); + dataRow = wrapper.find('table tbody tr').at(0); + dataRow.find('.dropdown-menu.show a').at(1).simulate('click'); + + await waitForComponentToPaint(wrapper); + const certificates = wrapper.find('Certificates'); + expect(certificates.html()).toEqual(expect.stringContaining(courseName)); + + expect(apiMock).toHaveBeenCalledTimes(1); + certificates.find('button.btn-outline-secondary').simulate('click'); + expect(wrapper.find('Certificates')).toEqual({}); + apiMock.mockReset(); + }); +}); diff --git a/src/users/v2/UserPage.jsx b/src/users/v2/UserPage.jsx new file mode 100644 index 000000000..672dca801 --- /dev/null +++ b/src/users/v2/UserPage.jsx @@ -0,0 +1,192 @@ +import { camelCaseObject, history } from '@edx/frontend-platform'; +import PropTypes from 'prop-types'; +import React, { + useCallback, useContext, useEffect, useState, +} from 'react'; +import { Link } from 'react-router-dom'; +import PageLoading from '../../components/common/PageLoading'; +import AlertList from '../../userMessages/AlertList'; +import { USER_IDENTIFIER_INVALID_ERROR } from '../../userMessages/messages'; +import UserMessagesContext from '../../userMessages/UserMessagesContext'; +import { isEmail, isValidUsername } from '../../utils/index'; +import { getAllUserData } from '../data/api'; +import Licenses from '../licenses/Licenses'; +import Entitlements from '../entitlements/Entitlements'; +import UserSearch from '../UserSearch'; +import UserSummary from '../UserSummary'; +import EnrollmentsV2 from '../enrollments/v2/Enrollments'; + +// Supports urls such as /users/?username={username} and /users/?email={email} +export default function UserPage({ location }) { + // converts query params from url into map e.g. ?param1=value1¶m2=value2 -> {param1: value1, param2=value2} + const params = new Map( + location.search + .slice(1) // removes '?' mark from start + .split('&') + .map(queryParams => queryParams.split('=')), + ); + + if (params.has('email')) { + const email = params.get('email'); + params.set('email', decodeURIComponent(email)); + } + + const [userIdentifier, setUserIdentifier] = useState( + params.get('username') || params.get('email') || undefined, + ); + const [searching, setSearching] = useState(false); + const [data, setData] = useState({ enrollments: null, entitlements: null }); + const [loading, setLoading] = useState(false); + const [showEntitlements, setShowEntitlements] = useState(false); + const [showLicenses, setShowLicenses] = useState(false); + const { add, clear } = useContext(UserMessagesContext); + + function pushHistoryIfChanged(nextUrl) { + if (nextUrl !== location.pathname + location.search) { + history.push(nextUrl); + } + } + + function processSearchResult(searchValue, result) { + if (result.errors.length > 0) { + result.errors.forEach((error) => add(error)); + history.replace('/usersv2'); + document.title = 'Support Tools | edX'; + } else if (isEmail(searchValue)) { + pushHistoryIfChanged(`/usersv2/?email=${searchValue}`); + document.title = `Support Tools | edX | ${searchValue}`; + } else if (isValidUsername(searchValue)) { + pushHistoryIfChanged(`/usersv2/?username=${searchValue}`); + document.title = `Support Tools | edX | ${searchValue}`; + } + + setLoading(false); + setSearching(false); + } + + function validateInput(input) { + if (!isValidUsername(input) && !isEmail(input)) { + clear('general'); + add({ + code: null, + dismissible: true, + text: USER_IDENTIFIER_INVALID_ERROR, + type: 'error', + topic: 'general', + }); + history.replace('/users'); + return false; + } + return true; + } + + const handleFetchSearchResults = useCallback((searchValue) => { + if (searchValue !== undefined && searchValue !== '') { + clear('general'); + if (!validateInput(searchValue)) { + return; + } + setUserIdentifier(searchValue); + setLoading(true); + getAllUserData(searchValue).then((result) => { + setData(camelCaseObject(result)); + processSearchResult(searchValue, result); + }); + // This is the case of an empty search (maybe a user wanted to clear out what they were seeing) + } else if (searchValue === '') { + clear('general'); + history.replace('/users'); + setLoading(false); + setSearching(false); + } + }); + + const handleSearchInputChange = useCallback((searchValue) => { + setSearching(true); + setShowEntitlements(false); + setShowLicenses(false); + handleFetchSearchResults(searchValue); + }); + + const handleUserSummaryChange = useCallback(() => { + setSearching(true); + handleFetchSearchResults(userIdentifier); + }); + + const handleEntitlementsChange = useCallback(() => { + setShowEntitlements(true); + setShowLicenses(true); + handleFetchSearchResults(userIdentifier); + }); + + const handleEnrollmentsChange = useCallback(() => { + setShowEntitlements(false); + setShowLicenses(false); + handleFetchSearchResults(userIdentifier); + }); + + useEffect(() => { + if (!searching) { + handleFetchSearchResults(userIdentifier); + } + }, [userIdentifier]); + + useEffect(() => { + if (params.get('username') && params.get('username') !== userIdentifier) { + handleFetchSearchResults(params.get('username')); + } else if (params.get('email') && params.get('email') !== userIdentifier) { + handleFetchSearchResults(params.get('email')); + } + }, [params.get('username'), params.get('email')]); + + return ( +
+
+ < Back to Tools +
+ + {/* NOTE: the "key" here causes the UserSearch component to re-render completely when the + user identifier changes. Doing so clears out the search box. */} + + {loading && } + {!loading && data.user && data.user.username && ( + <> + + + + + + + )} + {!loading && !userIdentifier && ( +
+

Please search for a username or email.

+
+ )} +
+ ); +} + +UserPage.propTypes = { + location: PropTypes.shape({ + pathname: PropTypes.string, + search: PropTypes.string, + }).isRequired, +};