From 4dcdc395d3d0f2f8ebf0a3ccb9ba268b4c2bca79 Mon Sep 17 00:00:00 2001 From: Hel Nershing Thapa <51614993+HelNershingThapa@users.noreply.github.com> Date: Tue, 31 May 2022 14:27:36 +0545 Subject: [PATCH] Improve management section placeholders (#5156) * Use explore projects placeholder for mgmt projects * Add teams placeholder in manage landing page * Add placeholder for organisations * Add placeholder for teams tab * Add campaigns placeholder * Add placeholder for categories tab * Add placeholder for users tab * Add placeholder for licenses tab * Show manage title when organization list is loading * Display placeholder when API is being fetched * Update test cases --- frontend/src/components/interests/index.js | 25 ++-- .../components/interests/tests/index.test.js | 63 ++++++++++ frontend/src/components/licenses/index.js | 25 ++-- .../licenses/licensesPlaceholder.js | 27 +++++ .../licenses/tests/licenses.test.js | 14 ++- .../src/components/teamsAndOrgs/campaigns.js | 25 ++-- .../teamsAndOrgs/campaignsPlaceholder.js | 27 +++++ .../components/teamsAndOrgs/organisations.js | 31 +++-- .../teamsAndOrgs/organisationsPlaceholder.js | 35 ++++++ .../src/components/teamsAndOrgs/projects.js | 7 +- frontend/src/components/teamsAndOrgs/teams.js | 32 ++--- .../teamsAndOrgs/teamsPlaceholder.js | 35 ++++++ .../teamsAndOrgs/tests/campaigns.test.js | 67 +++++++++++ .../teamsAndOrgs/tests/organisations.test.js | 47 +++++++- .../teamsAndOrgs/tests/projects.test.js | 18 +++ .../teamsAndOrgs/tests/teams.test.js | 111 +++++++++++++++++- frontend/src/components/user/list.js | 57 +++++---- .../src/components/user/tests/list.test.js | 38 ++++++ .../src/components/user/usersPlaceholder.js | 40 +++++++ .../src/network/tests/mockData/userList.js | 29 +++++ frontend/src/network/tests/server-handlers.js | 4 + frontend/src/views/campaigns.js | 27 ++--- frontend/src/views/interests.js | 26 +--- frontend/src/views/licenses.js | 26 +--- frontend/src/views/organisationManagement.js | 43 +++---- frontend/src/views/teams.js | 45 +++---- 26 files changed, 730 insertions(+), 194 deletions(-) create mode 100644 frontend/src/components/interests/tests/index.test.js create mode 100644 frontend/src/components/licenses/licensesPlaceholder.js create mode 100644 frontend/src/components/teamsAndOrgs/campaignsPlaceholder.js create mode 100644 frontend/src/components/teamsAndOrgs/organisationsPlaceholder.js create mode 100644 frontend/src/components/teamsAndOrgs/teamsPlaceholder.js create mode 100644 frontend/src/components/teamsAndOrgs/tests/campaigns.test.js create mode 100644 frontend/src/components/teamsAndOrgs/tests/projects.test.js create mode 100644 frontend/src/components/user/tests/list.test.js create mode 100644 frontend/src/components/user/usersPlaceholder.js create mode 100644 frontend/src/network/tests/mockData/userList.js diff --git a/frontend/src/components/interests/index.js b/frontend/src/components/interests/index.js index 636cdeccc8..ac6d5d3a5c 100644 --- a/frontend/src/components/interests/index.js +++ b/frontend/src/components/interests/index.js @@ -2,11 +2,13 @@ import React from 'react'; import { Link } from '@reach/router'; import { Form, Field } from 'react-final-form'; import { FormattedMessage } from 'react-intl'; +import ReactPlaceholder from 'react-placeholder'; import messages from '../teamsAndOrgs/messages'; import { Management } from '../teamsAndOrgs/management'; import { HashtagIcon } from '../svgIcons'; import { Button } from '../button'; +import { nCardPlaceholders } from '../teamsAndOrgs/campaignsPlaceholder'; export const InterestCard = ({ interest }) => { return ( @@ -25,7 +27,7 @@ export const InterestCard = ({ interest }) => { ); }; -export const InterestsManagement = ({ interests, userDetails }) => { +export const InterestsManagement = ({ interests, _userDetails, isInterestsFetched }) => { return ( { showAddButton={true} managementView > - {interests.length ? ( - interests.map((i, n) => ) - ) : ( -
- -
- )} + + {interests?.length ? ( + interests.map((i, n) => ) + ) : ( +
+ +
+ )} +
); }; diff --git a/frontend/src/components/interests/tests/index.test.js b/frontend/src/components/interests/tests/index.test.js new file mode 100644 index 0000000000..6074e2c5b2 --- /dev/null +++ b/frontend/src/components/interests/tests/index.test.js @@ -0,0 +1,63 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IntlProviders } from '../../../utils/testWithIntl'; +import { InterestsManagement } from '../index'; + +const dummyInterests = [ + { + id: 1, + name: 'Interest 1', + }, + { + id: 2, + name: 'Interest 2', + }, +]; + +describe('InterestsManagement component', () => { + it('renders loading placeholder when API is being fetched', () => { + const { container, getByRole } = render( + + + , + ); + expect( + screen.getByRole('heading', { + name: /manage categories/i, + }), + ).toBeInTheDocument(); + expect(container.getElementsByClassName('show-loading-animation')).toHaveLength(4); + expect( + getByRole('button', { + name: /new/i, + }), + ).toBeInTheDocument(); + }); + + it('does not render loading placeholder after API is fetched', () => { + const { container } = render( + + + , + ); + expect(container.getElementsByClassName('show-loading-animation')).toHaveLength(0); + }); + + it('renders interests list card after API is fetched', async () => { + const { container, getByText } = render( + + + , + ); + expect( + screen.getByRole('heading', { + name: /manage categories/i, + }), + ).toBeInTheDocument(); + await waitFor(() => { + expect(getByText(/Interest 1/i)); + }); + expect(getByText(/Interest 2/i)).toBeInTheDocument(); + expect(container.querySelectorAll('svg').length).toBe(3); + }); +}); diff --git a/frontend/src/components/licenses/index.js b/frontend/src/components/licenses/index.js index 9df751d22b..62dafaa8c5 100644 --- a/frontend/src/components/licenses/index.js +++ b/frontend/src/components/licenses/index.js @@ -2,11 +2,13 @@ import React from 'react'; import { Link } from '@reach/router'; import { Form, Field } from 'react-final-form'; import { FormattedMessage } from 'react-intl'; +import ReactPlaceholder from 'react-placeholder'; import messages from '../teamsAndOrgs/messages'; import { Management } from '../teamsAndOrgs/management'; import { CopyrightIcon } from '../svgIcons'; import { Button } from '../button'; +import { nCardPlaceholders } from './licensesPlaceholder'; export const LicenseCard = ({ license }) => { return ( @@ -25,7 +27,7 @@ export const LicenseCard = ({ license }) => { ); }; -export const LicensesManagement = ({ licenses, userDetails }) => { +export const LicensesManagement = ({ licenses, userDetails, isLicensesFetched }) => { return ( { showAddButton={true} managementView > - {licenses.length ? ( - licenses.map((i, n) => ) - ) : ( -
- -
- )} + + {licenses?.length ? ( + licenses.map((i, n) => ) + ) : ( +
+ +
+ )} +
); }; diff --git a/frontend/src/components/licenses/licensesPlaceholder.js b/frontend/src/components/licenses/licensesPlaceholder.js new file mode 100644 index 0000000000..2d53f92def --- /dev/null +++ b/frontend/src/components/licenses/licensesPlaceholder.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { TextRow } from 'react-placeholder/lib/placeholders'; +import { CopyrightIcon } from '../svgIcons'; + +export const licenseCardPlaceholderTemplate = () => (_n, i) => + ( +
+
+
+
+ + + +
+
+ +
+
+ ); + +export const nCardPlaceholders = (N) => { + return [...Array(N).keys()].map(licenseCardPlaceholderTemplate()); +}; diff --git a/frontend/src/components/licenses/tests/licenses.test.js b/frontend/src/components/licenses/tests/licenses.test.js index 810306584c..b4f9b96282 100644 --- a/frontend/src/components/licenses/tests/licenses.test.js +++ b/frontend/src/components/licenses/tests/licenses.test.js @@ -40,7 +40,7 @@ describe('Licenses Management', () => { it('renders all licenses and button to add a new license', () => { const { container } = render( - + , ); expect(container.querySelector('h3').innerHTML).toBe('Manage Licenses'); @@ -53,6 +53,18 @@ describe('Licenses Management', () => { const license2 = screen.getByText(/NextView/); expect(license2.closest('a').href).toContain('/2'); }); + + it('renders placeholder and not licenses when API is being fetched', () => { + const { container } = render( + + + , + ); + expect(screen.queryByText(/HOT Licence/)).not.toBeInTheDocument(); + expect(screen.queryByText(/NextView/)).not.toBeInTheDocument(); + expect(container.querySelectorAll('svg').length).toBe(5); // 4 plus the new icon svg + expect(container.querySelector('.show-loading-animation')).toBeInTheDocument(); + }); }); describe('LicenseForm', () => { diff --git a/frontend/src/components/teamsAndOrgs/campaigns.js b/frontend/src/components/teamsAndOrgs/campaigns.js index 8ce11bb756..7649f76508 100644 --- a/frontend/src/components/teamsAndOrgs/campaigns.js +++ b/frontend/src/components/teamsAndOrgs/campaigns.js @@ -2,13 +2,15 @@ import React from 'react'; import { Link } from '@reach/router'; import { FormattedMessage } from 'react-intl'; import { Form, Field } from 'react-final-form'; +import ReactPlaceholder from 'react-placeholder'; +import { nCardPlaceholders } from './campaignsPlaceholder'; import messages from './messages'; import { Management } from './management'; import { Button } from '../button'; import { HashtagIcon } from '../svgIcons'; -export function CampaignsManagement({ campaigns, userDetails }: Object) { +export function CampaignsManagement({ campaigns, userDetails, isCampaignsFetched }: Object) { return ( - {campaigns.length ? ( - campaigns.map((campaign, n) => ) - ) : ( -
- -
- )} + + {campaigns?.length ? ( + campaigns.map((campaign, n) => ) + ) : ( +
+ +
+ )} +
); } diff --git a/frontend/src/components/teamsAndOrgs/campaignsPlaceholder.js b/frontend/src/components/teamsAndOrgs/campaignsPlaceholder.js new file mode 100644 index 0000000000..17902f5790 --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/campaignsPlaceholder.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { TextRow } from 'react-placeholder/lib/placeholders'; +import { HashtagIcon } from '../svgIcons'; + +export const campaignCardPlaceholderTemplate = () => (_n, i) => + ( +
+
+
+
+ + + +
+
+ +
+
+ ); + +export const nCardPlaceholders = (N) => { + return [...Array(N).keys()].map(campaignCardPlaceholderTemplate()); +}; diff --git a/frontend/src/components/teamsAndOrgs/organisations.js b/frontend/src/components/teamsAndOrgs/organisations.js index 15e641f28d..10b782941e 100644 --- a/frontend/src/components/teamsAndOrgs/organisations.js +++ b/frontend/src/components/teamsAndOrgs/organisations.js @@ -15,6 +15,7 @@ import { Management } from './management'; import { InternalLinkIcon, ClipboardIcon } from '../svgIcons'; import { Button } from '../button'; import { UserAvatarList } from '../user/avatar'; +import { nCardPlaceholders } from './organisationsPlaceholder'; export function OrgsManagement({ organisations, @@ -22,6 +23,7 @@ export function OrgsManagement({ isAdmin, userOrgsOnly, setUserOrgsOnly, + isOrganisationsFetched, }: Object) { return ( - {isOrgManager ? ( - organisations.length ? ( - organisations.map((org, n) => ) + + {isOrgManager ? ( + organisations?.length ? ( + organisations.map((org, n) => ) + ) : ( +
+ +
+ ) ) : ( -
- +
+
- ) - ) : ( -
- -
- )} + )} + ); } diff --git a/frontend/src/components/teamsAndOrgs/organisationsPlaceholder.js b/frontend/src/components/teamsAndOrgs/organisationsPlaceholder.js new file mode 100644 index 0000000000..696f0cee5d --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/organisationsPlaceholder.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { TextRow, RoundShape, RectShape } from 'react-placeholder/lib/placeholders'; + +export const organisationCardPlaceholderTemplate = () => (_n, i) => + ( +
+
+
+ +
+
+ + +
+ {[...Array(2)].map((_, i) => ( + + ))} +
+
+
+
+ ); + +export const nCardPlaceholders = (N) => { + return [...Array(N).keys()].map(organisationCardPlaceholderTemplate()); +}; diff --git a/frontend/src/components/teamsAndOrgs/projects.js b/frontend/src/components/teamsAndOrgs/projects.js index faae71b81e..c249501410 100644 --- a/frontend/src/components/teamsAndOrgs/projects.js +++ b/frontend/src/components/teamsAndOrgs/projects.js @@ -6,6 +6,7 @@ import ReactPlaceholder from 'react-placeholder'; import messages from './messages'; import { ProjectCard } from '../projectCard/projectCard'; import { AddButton, ViewAllLink } from './management'; +import { nCardPlaceholders } from '../projectCard/nCardPlaceholder'; export function Projects({ projects, @@ -29,12 +30,10 @@ export function Projects({
{projects && projects.results && diff --git a/frontend/src/components/teamsAndOrgs/teams.js b/frontend/src/components/teamsAndOrgs/teams.js index b91ec7c0a5..82f05ba5f2 100644 --- a/frontend/src/components/teamsAndOrgs/teams.js +++ b/frontend/src/components/teamsAndOrgs/teams.js @@ -11,6 +11,7 @@ import { UserAvatar, UserAvatarList } from '../user/avatar'; import { AddButton, ViewAllLink, Management, VisibilityBox, InviteOnlyBox } from './management'; import { SwitchToggle, RadioField, OrganisationSelectInput } from '../formInputs'; import { Button, EditButton } from '../button'; +import { nCardPlaceholders } from './teamsPlaceholder'; export function TeamsManagement({ teams, @@ -18,6 +19,7 @@ export function TeamsManagement({ managementView, userTeamsOnly, setUserTeamsOnly, + isTeamsFetched, }: Object) { const isOrgManager = useSelector( (state) => state.auth.get('organisations') && state.auth.get('organisations').length > 0, @@ -42,13 +44,20 @@ export function TeamsManagement({ setUserOnly={setUserTeamsOnly} userOnlyLabel={} > - {teams.length ? ( - teams.map((team, n) => ) - ) : ( -
- -
- )} + + {teams?.length ? ( + teams.map((team, n) => ) + ) : ( +
+ +
+ )} +
); } @@ -67,14 +76,7 @@ export function Teams({ teams, viewAllQuery, showAddButton = false, isReady, bor )} {viewAllQuery && }
- + {teams && teams.slice(0, 6).map((team, n) => )} {teams && teams.length === 0 && ( diff --git a/frontend/src/components/teamsAndOrgs/teamsPlaceholder.js b/frontend/src/components/teamsAndOrgs/teamsPlaceholder.js new file mode 100644 index 0000000000..2f829e02be --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/teamsPlaceholder.js @@ -0,0 +1,35 @@ +import React, { Fragment } from 'react'; + +import { TextRow, TextBlock, RoundShape, RectShape } from 'react-placeholder/lib/placeholders'; + +export const teamCardPlaceholderTemplate = () => (_n, i) => + ( +
+
+ + + {[...Array(2)].map((_, i) => ( + + + {[...Array(2)].map((_, i) => ( + + ))} + + ))} + +
+
+ ); + +export const nCardPlaceholders = (N) => { + return [...Array(N).keys()].map(teamCardPlaceholderTemplate()); +}; diff --git a/frontend/src/components/teamsAndOrgs/tests/campaigns.test.js b/frontend/src/components/teamsAndOrgs/tests/campaigns.test.js new file mode 100644 index 0000000000..cd32300faa --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/tests/campaigns.test.js @@ -0,0 +1,67 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IntlProviders } from '../../../utils/testWithIntl'; +import { CampaignsManagement } from '../campaigns'; + +const dummyCampaigns = [ + { + id: 1, + name: 'Campaign 1', + }, + { + id: 2, + name: 'Campaign 2', + }, +]; + +describe('CampaignsManagement component', () => { + it('renders loading placeholder when API is being fetched', () => { + const { container, getByRole } = render( + + + , + ); + expect( + screen.getByRole('heading', { + name: /manage campaigns/i, + }), + ).toBeInTheDocument(); + expect(container.getElementsByClassName('show-loading-animation')).toHaveLength(4); + expect( + getByRole('button', { + name: /new/i, + }), + ).toBeInTheDocument(); + }); + + it('does not render loading placeholder after API is fetched', () => { + const { container } = render( + + + , + ); + expect(container.getElementsByClassName('show-loading-animation')).toHaveLength(0); + }); + + it('renders campaigns list card after API is fetched', async () => { + const { container, getByText } = render( + + + , + ); + expect( + screen.getByRole('heading', { + name: /manage campaigns/i, + }), + ).toBeInTheDocument(); + await waitFor(() => { + expect(getByText(/Campaign 1/i)); + }); + expect(getByText(/Campaign 2/i)).toBeInTheDocument(); + expect(container.querySelectorAll('svg').length).toBe(3); + }); +}); diff --git a/frontend/src/components/teamsAndOrgs/tests/organisations.test.js b/frontend/src/components/teamsAndOrgs/tests/organisations.test.js index 2e8e92a141..a78bcd7607 100644 --- a/frontend/src/components/teamsAndOrgs/tests/organisations.test.js +++ b/frontend/src/components/teamsAndOrgs/tests/organisations.test.js @@ -61,7 +61,12 @@ describe('OrgsManagement with', () => { }; it('isOrgManager = false and isAdmin = false should NOT list organisations', () => { const element = createComponentWithIntl( - , + , ); const testInstance = element.root; expect(testInstance.findAllByType(FormattedMessage).map((i) => i.props.id)).toContain( @@ -77,7 +82,12 @@ describe('OrgsManagement with', () => { it('isOrgManager and isAdmin SHOULD list organisations and have a link to /new ', () => { const element = createComponentWithIntl( - , + , ); const testInstance = element.root; expect(testInstance.findByType(OrganisationCard).props.details).toStrictEqual( @@ -99,7 +109,12 @@ describe('OrgsManagement with', () => { it('OrgsManagement with isOrgManager = true and isAdmin = false SHOULD list organisations, but should NOT have an AddButton', () => { const element = createComponentWithIntl( - , + , ); const testInstance = element.root; expect(testInstance.findByType(OrganisationCard).props.details).toStrictEqual( @@ -109,4 +124,30 @@ describe('OrgsManagement with', () => { new Error('No instances found with node type: "AddButton"'), ); }); + + it('renders loading placeholder when API is being fetched', () => { + const element = createComponentWithIntl( + , + ); + const testInstance = element.root; + expect(testInstance.findAllByProps({ className: 'show-loading-animation' }).length).toBe(4); + }); + + it('should not render loading placeholder after API is fetched', () => { + const element = createComponentWithIntl( + , + ); + const testInstance = element.root; + expect(testInstance.findAllByProps({ className: 'show-loading-animation' }).length).toBe(0); + }); }); diff --git a/frontend/src/components/teamsAndOrgs/tests/projects.test.js b/frontend/src/components/teamsAndOrgs/tests/projects.test.js new file mode 100644 index 0000000000..5a503c9de5 --- /dev/null +++ b/frontend/src/components/teamsAndOrgs/tests/projects.test.js @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IntlProviders } from '../../../utils/testWithIntl'; +import { Projects } from '../projects'; + +it('renders loading placeholder when API is being fetched', () => { + const { container } = render( + + + , + ); + expect( + screen.getByRole('heading', { + name: /projects/i, + }), + ).toBeInTheDocument(); + expect(container.getElementsByClassName('show-loading-animation')).toHaveLength(16); +}); diff --git a/frontend/src/components/teamsAndOrgs/tests/teams.test.js b/frontend/src/components/teamsAndOrgs/tests/teams.test.js index 2b3e3e576c..1f383f13ea 100644 --- a/frontend/src/components/teamsAndOrgs/tests/teams.test.js +++ b/frontend/src/components/teamsAndOrgs/tests/teams.test.js @@ -1,9 +1,10 @@ import React from 'react'; import TestRenderer from 'react-test-renderer'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; import { FormattedMessage } from 'react-intl'; - -import { createComponentWithIntl } from '../../../utils/testWithIntl'; -import { TeamBox, TeamsBoxList } from '../teams'; +import { createComponentWithIntl, ReduxIntlProviders } from '../../../utils/testWithIntl'; +import { TeamBox, TeamsBoxList, TeamsManagement } from '../teams'; describe('test TeamBox', () => { const element = TestRenderer.create( @@ -98,3 +99,107 @@ describe('test TeamBoxList without mapping and validation teams', () => { ); }); }); + +describe('TeamsManagement component', () => { + it('renders loading placeholder when API is being fetched', async () => { + const { container, getByRole } = render( + + + , + ); + expect( + getByRole('button', { + name: /new/i, + }), + ).toBeInTheDocument(); + expect(container.querySelectorAll('button')).toHaveLength(3); + expect(container.getElementsByClassName('show-loading-animation mb3')).toHaveLength(4); + }); + + it('does not render loading placeholder after API is fetched', () => { + const { container } = render( + + + , + ); + expect(container.getElementsByClassName('show-loading-animation mb3')).toHaveLength(0); + }); + + it("should not render 'Manage teams' but render 'My teams' text for non management view", () => { + render( + + + , + ); + expect( + screen.getByRole('heading', { + name: /manage teams/i, + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { + name: /my teams/i, + }), + ).not.toBeInTheDocument(); + }); + + it('renders teams list card after API is fetched', async () => { + const dummyTeams = [ + { + teamId: 3, + name: 'My Best Team', + role: 'PROJECT_MANAGER', + members: [ + { + username: 'ram', + function: 'MEMBER', + active: true, + pictureUrl: null, + }, + ], + }, + ]; + const { container, getByText } = render( + + + , + ); + expect(container.querySelectorAll('h3')[0].textContent).toBe('Manage Teams'); + expect(container.querySelectorAll('article').length).toBe(1); + expect(getByText('My Best Team')).toBeInTheDocument(); + expect(getByText('Managers')).toBeInTheDocument(); + expect(getByText('Team members')).toBeInTheDocument(); + expect(getByText('My Best Team').closest('a').href).toContain('/manage/teams/3/'); + }); + + it('renders relevant text if user is not a member of any team', async () => { + render( + + + , + ); + expect(screen.getByText(/you are not a member of a team yet\./i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/user/list.js b/frontend/src/components/user/list.js index 4b05182d37..bcb7ec81d9 100644 --- a/frontend/src/components/user/list.js +++ b/frontend/src/components/user/list.js @@ -1,6 +1,7 @@ import React, { useEffect, useState, useRef } from 'react'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; +import ReactPlaceholder from 'react-placeholder'; import messages from './messages'; import { UserAvatar } from './avatar'; @@ -10,6 +11,7 @@ import { SearchIcon, CloseIcon } from '../svgIcons'; import { Dropdown } from '../dropdown'; import { SettingsIcon, CheckIcon } from '../svgIcons'; import Popup from 'reactjs-popup'; +import { nCardPlaceholders } from './usersPlaceholder'; const UserFilter = ({ filters, setFilters, updateFilters, intl }) => { const inputRef = useRef(null); @@ -146,12 +148,19 @@ export const UsersTable = ({ filters, setFilters }) => { const [response, setResponse] = useState(null); const userDetails = useSelector((state) => state.auth.get('userDetails')); const [status, setStatus] = useState({ status: null, message: '' }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); useEffect(() => { const fetchUsers = async (filters) => { + setLoading(true); const url = `users/?${filters}`; - const res = await fetchLocalJSONAPI(url, token); - setResponse(res); + fetchLocalJSONAPI(url, token) + .then((res) => { + setResponse(res); + setLoading(false); + }) + .catch((err) => setError(err)); }; // Filter elements according to logic. @@ -176,27 +185,35 @@ export const UsersTable = ({ filters, setFilters }) => { fetchUsers(urlFilters); }, [filters, token, status]); - if (response === null) { - return null; - } - return (
-

- -

+ {response?.users && ( +

+ +

+ )}
-
    - {response.users.map((user) => ( - - ))} -
+ +
    + {response?.users.map((user) => ( + + ))} +
+
{response === null || response.pagination.total === 0 ? null : ( { + it('renders user card', async () => { + const { container, getByText, getAllByRole } = render( + + + , + ); + await waitFor(() => { + expect(getByText(/Ram/i)); + }); + expect(getAllByRole('listitem')).toHaveLength(2); + expect(getByText(/total number of users: 220111/i)).toBeInTheDocument(); + expect(screen.getByText('Ram').closest('a')).toHaveAttribute('href', '/users/Ram'); + expect(screen.getAllByText('Mapper').length).toBe(2); + expect(getByText('Beginner')).toBeInTheDocument(); + expect(getByText('Shyam')).toBeInTheDocument(); + expect(screen.getByText('Shyam').closest('a')).toHaveAttribute('href', '/users/Shyam'); + expect(screen.getByTitle(/Ram/i)).toHaveStyle( + `background-image: url(https://www.openstreetmap.org/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBNXQ2Q3c9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--fe41f1b2a5d6cf492a7133f15c81f105dec06ff7/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBPZ2h3Ym1jNkZISmxjMmw2WlY5MGIxOXNhVzFwZEZzSGFXbHBhUT09IiwiZXhwIjpudWxsLCJwdXIiOiJ2YXJpYXRpb24ifX0=--058ac785867b32287d598a314311e2253bd879a3/unnamed.webp)`, + ); + expect(container.querySelectorAll('svg').length).toBe(2); + }); +}); diff --git a/frontend/src/components/user/usersPlaceholder.js b/frontend/src/components/user/usersPlaceholder.js new file mode 100644 index 0000000000..d301ab54f0 --- /dev/null +++ b/frontend/src/components/user/usersPlaceholder.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { TextRow, RoundShape } from 'react-placeholder/lib/placeholders'; + +export const userCardPlaceholderTemplate = () => (_n, i) => + ( +
+
+ + +
+
+ +
+
+ +
+
+ ); + +export const nCardPlaceholders = (N) => { + return [...Array(N).keys()].map(userCardPlaceholderTemplate()); +}; diff --git a/frontend/src/network/tests/mockData/userList.js b/frontend/src/network/tests/mockData/userList.js new file mode 100644 index 0000000000..0c217dfac3 --- /dev/null +++ b/frontend/src/network/tests/mockData/userList.js @@ -0,0 +1,29 @@ +export const usersList = { + users: [ + { + id: 1, + username: 'Ram', + role: 'MAPPER', + mappingLevel: 'BEGINNER', + pictureUrl: + 'https://www.openstreetmap.org/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBNXQ2Q3c9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--fe41f1b2a5d6cf492a7133f15c81f105dec06ff7/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBPZ2h3Ym1jNkZISmxjMmw2WlY5MGIxOXNhVzFwZEZzSGFXbHBhUT09IiwiZXhwIjpudWxsLCJwdXIiOiJ2YXJpYXRpb24ifX0=--058ac785867b32287d598a314311e2253bd879a3/unnamed.webp', + }, + { + id: 2, + username: 'Shyam', + role: 'MAPPER', + mappingLevel: 'ADVANCED', + pictureUrl: null, + }, + ], + pagination: { + hasNext: true, + hasPrev: false, + nextNum: 2, + page: 1, + pages: 11006, + prevNum: null, + perPage: 20, + total: 220111, + }, +}; diff --git a/frontend/src/network/tests/server-handlers.js b/frontend/src/network/tests/server-handlers.js index 7cf28e25a1..998b0bd419 100644 --- a/frontend/src/network/tests/server-handlers.js +++ b/frontend/src/network/tests/server-handlers.js @@ -4,6 +4,7 @@ import { getProjectSummary, getProjectStats } from './mockData/projects'; import { featuredProjects } from './mockData/featuredProjects'; import { newUsersStats } from './mockData/userStats'; import { projectContributions, projectContributionsByDay } from './mockData/contributions'; +import { usersList } from './mockData/userList'; import tasksGeojson from '../../utils/tests/snippets/tasksGeometry'; import { API_URL } from '../../config'; @@ -34,6 +35,9 @@ const handlers = [ rest.get(API_URL + 'users/statistics/', async (req, res, ctx) => { return res(ctx.json(newUsersStats)); }), + rest.get(API_URL + 'users', async (req, res, ctx) => { + return res(ctx.json(usersList)); + }), ]; export { handlers }; diff --git a/frontend/src/views/campaigns.js b/frontend/src/views/campaigns.js index e114e696e0..e3d8b12f1a 100644 --- a/frontend/src/views/campaigns.js +++ b/frontend/src/views/campaigns.js @@ -1,8 +1,7 @@ import React, { useState } from 'react'; import { useSelector } from 'react-redux'; import { Link, useNavigate } from '@reach/router'; -import ReactPlaceholder from 'react-placeholder'; -import { TextBlock, RectShape } from 'react-placeholder/lib/placeholders'; + import { FormattedMessage } from 'react-intl'; import { Form } from 'react-final-form'; @@ -40,26 +39,14 @@ export function ListCampaigns() { const userDetails = useSelector((state) => state.auth.get('userDetails')); // TO DO: filter teams of current user const [error, loading, campaigns] = useFetch(`campaigns/`); - - const placeHolder = ( -
-
- -
- - -
- ); + const isCampaignsFetched = !loading && !error; return ( - - - + ); } diff --git a/frontend/src/views/interests.js b/frontend/src/views/interests.js index 895bc5e795..c7e54c461d 100644 --- a/frontend/src/views/interests.js +++ b/frontend/src/views/interests.js @@ -1,8 +1,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { useFetch } from '../hooks/UseFetch'; -import { TextBlock, RectShape } from 'react-placeholder/lib/placeholders'; -import ReactPlaceholder from 'react-placeholder'; import { Link, useNavigate } from '@reach/router'; import { Form } from 'react-final-form'; import { FormattedMessage } from 'react-intl'; @@ -76,26 +74,14 @@ export const ListInterests = () => { const userDetails = useSelector((state) => state.auth.get('userDetails')); // TO DO: filter teams of current user const [error, loading, interests] = useFetch(`interests/`); - - const placeHolder = ( -
-
- -
- - -
- ); + const isInterestsFetched = !loading && !error; return ( - - - + ); }; diff --git a/frontend/src/views/licenses.js b/frontend/src/views/licenses.js index 1049a718fd..8e5b02f7c6 100644 --- a/frontend/src/views/licenses.js +++ b/frontend/src/views/licenses.js @@ -2,8 +2,6 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { useFetch } from '../hooks/UseFetch'; import { useSetTitleTag } from '../hooks/UseMetaTags'; -import { TextBlock, RectShape } from 'react-placeholder/lib/placeholders'; -import ReactPlaceholder from 'react-placeholder'; import { Link, useNavigate } from '@reach/router'; import { Form } from 'react-final-form'; import { FormattedMessage } from 'react-intl'; @@ -49,26 +47,14 @@ export const ListLicenses = () => { const userDetails = useSelector((state) => state.auth.get('userDetails')); // TO DO: filter teams of current user const [error, loading, licenses] = useFetch(`licenses/`); - - const placeHolder = ( -
-
- -
- - -
- ); + const isLicensesFetched = !loading && !error; return ( - - - + ); }; diff --git a/frontend/src/views/organisationManagement.js b/frontend/src/views/organisationManagement.js index b3a3eea953..4bc630033e 100644 --- a/frontend/src/views/organisationManagement.js +++ b/frontend/src/views/organisationManagement.js @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { Link, useNavigate } from '@reach/router'; import ReactPlaceholder from 'react-placeholder'; -import { RectShape } from 'react-placeholder/lib/placeholders'; import { FormattedMessage } from 'react-intl'; import { Form } from 'react-final-form'; @@ -32,37 +31,31 @@ export function ListOrganisations() { ); const [organisations, setOrganisations] = useState(null); const [userOrgsOnly, setUserOrgsOnly] = useState(userDetails.role === 'ADMIN' ? false : true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { if (token && userDetails && userDetails.id) { + setLoading(true); const queryParam = `${userOrgsOnly ? `?manager_user_id=${userDetails.id}` : ''}`; - fetchLocalJSONAPI(`organisations/${queryParam}`, token).then((orgs) => - setOrganisations(orgs.organisations), - ); + fetchLocalJSONAPI(`organisations/${queryParam}`, token) + .then((orgs) => { + setOrganisations(orgs.organisations); + setLoading(false); + }) + .catch((err) => setError(err)); } }, [userDetails, token, userOrgsOnly]); - const placeHolder = ( -
- - -
- ); - return ( - - - + ); } diff --git a/frontend/src/views/teams.js b/frontend/src/views/teams.js index eccbf453ca..19006c8a76 100644 --- a/frontend/src/views/teams.js +++ b/frontend/src/views/teams.js @@ -1,8 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { Link, useNavigate } from '@reach/router'; -import ReactPlaceholder from 'react-placeholder'; -import { TextBlock, RectShape } from 'react-placeholder/lib/placeholders'; import { FormattedMessage } from 'react-intl'; import { Form } from 'react-final-form'; @@ -50,6 +48,8 @@ export function ListTeams({ managementView = false }: Object) { const token = useSelector((state) => state.auth.get('token')); const [teams, setTeams] = useState(null); const [userTeamsOnly, setUserTeamsOnly] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); useEffect(() => { if (token && userDetails && userDetails.id) { @@ -59,36 +59,25 @@ export function ListTeams({ managementView = false }: Object) { } else { queryParam = `?member=${userDetails.id}`; } - fetchLocalJSONAPI(`teams/${queryParam}`, token).then((res) => setTeams(res.teams)); + setLoading(true); + fetchLocalJSONAPI(`teams/${queryParam}`, token) + .then((res) => { + setTeams(res.teams); + setLoading(false); + }) + .catch((err) => setError(err)); } }, [userDetails, token, managementView, userTeamsOnly]); - const placeHolder = ( -
-
- - -
- - -
- ); - return ( - - - + ); }