Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ws 119/admin listing page #6179

Merged
merged 44 commits into from
Apr 1, 2021
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
f1a1e6d
Create mocked search API endpoint
robmarch2 Mar 4, 2021
8676776
Add react router dependency
robmarch2 Mar 4, 2021
ad11160
Create middleware API caller with typed response
robmarch2 Mar 4, 2021
a6e2dd1
Create utils for building search URLs
robmarch2 Mar 4, 2021
7bdbd19
Create search components
robmarch2 Mar 4, 2021
9506630
Add search component to homepage
robmarch2 Mar 4, 2021
dcdbe74
Add stylesheets
robmarch2 Mar 4, 2021
bafdfaf
Expand pagination component
robmarch2 Mar 4, 2021
8f79bfc
Remove magic numbers from pagination
robmarch2 Mar 4, 2021
f0a096c
Remove intermediate state on search forms
robmarch2 Mar 4, 2021
2b3c3a2
Hide placeholder text on status dropdown
robmarch2 Mar 4, 2021
a0df4a4
Fix linting issue
robmarch2 Mar 4, 2021
35679e7
Remove unnecessary dependency
robmarch2 Mar 4, 2021
e7a82cd
Provide default value for page in mock API
robmarch2 Mar 4, 2021
b730b8b
Use query string to build search URLs
robmarch2 Mar 4, 2021
d7e892c
Make page number cast in mock API more explicit
robmarch2 Mar 4, 2021
48069ee
Merge branches 'master' and 'WS-119/admin-listing-page' of https://gi…
robmarch2 Mar 9, 2021
2d80d91
Remove app CSS
robmarch2 Mar 9, 2021
ab83f28
Use standardised pretty date module
robmarch2 Mar 9, 2021
36d0853
Remove unnecessary dependency
robmarch2 Mar 9, 2021
1a59d01
Use correct query string library to build URLs
robmarch2 Mar 9, 2021
aac2a27
Simplify last login date field
robmarch2 Mar 10, 2021
f6bfa4c
Remove redundant sort field check
robmarch2 Mar 10, 2021
115a802
Add pagination tests
robmarch2 Mar 10, 2021
a876b05
Add sorter tests
robmarch2 Mar 10, 2021
1683380
Add status dropdown tests
robmarch2 Mar 10, 2021
1eb0f09
UI and UX changes to make the user listing page
robmarch2 Mar 17, 2021
1e26551
Improve styling of status dropdown
robmarch2 Mar 23, 2021
26ceba6
Merge branch 'master' of https://github.com/wellcomecollection/wellco…
robmarch2 Mar 23, 2021
d2cc332
Ensure correct query param is used to build search URL
robmarch2 Mar 23, 2021
c5079d8
Style user list item to appear clickable
robmarch2 Mar 24, 2021
7053bbc
Integrate user listing page with real API
robmarch2 Mar 24, 2021
366645b
Remove unusable sort field
robmarch2 Mar 24, 2021
e0dcbc1
Improve pagingation tests
robmarch2 Mar 31, 2021
1a8859a
Improve sorter tests
robmarch2 Mar 31, 2021
c3167b8
Improve status dropdown tests
robmarch2 Mar 31, 2021
f34c2a0
Remove unnecessary logging from test
robmarch2 Mar 31, 2021
2506f92
Wrap user list entry in link
robmarch2 Mar 31, 2021
f208633
Update yarn.lock
robmarch2 Mar 31, 2021
c635a6f
Merge branches 'master' and 'WS-119/admin-listing-page' of https://gi…
robmarch2 Mar 31, 2021
bb5d259
Update yarn.lock
robmarch2 Mar 31, 2021
d5b81fc
Improve selctors for tests
robmarch2 Apr 1, 2021
f123a89
Improved semantics for table header
robmarch2 Apr 1, 2021
7c9156d
Increase contrast of disabled pagination links
robmarch2 Apr 1, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions identity-admin/webapp/components/search/Pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Pagination from './Pagination';
import React from 'react';
import { render, screen } from '@testing-library/react';

const renderComponent = (currentPage: number, pageCount: number) =>
render(<Pagination currentPage={currentPage} pageCount={pageCount} />);
const getAllLinks = () => screen.getAllByRole('link');
const expectedLinkUrl = (expectedPage: string) =>
ajrussellaudio marked this conversation as resolved.
Show resolved Hide resolved
'/?email=bob%40email.com&name=Bob&page=' +
expectedPage +
'&sort=email&sortDir=1&status=active';

jest.mock('next/router', () => ({
useRouter: () => {
return {
query: {
status: 'active',
name: 'Bob',
email: '[email protected]',
sort: 'email',
sortDir: '1',
},
};
},
}));

describe('Pagination', () => {
it('disables previous link on first page', () => {
renderComponent(1, 3);
const previous = screen.getByText('Previous');
expect(previous).toHaveClass('user-pagination__item--disabled');
expect(previous).not.toHaveAttribute('href');
});

it('disables next link on last page', () => {
renderComponent(3, 3);
const next = screen.getByText('Next');
expect(next).toHaveClass('user-pagination__item--disabled');
expect(next).not.toHaveAttribute('href');
});

it('shows one numbered previous page link on second page', () => {
renderComponent(2, 3);
const previous = getAllLinks()[1];
expect(previous).not.toHaveClass('user-pagination__item--disabled');
expect(previous).toHaveAttribute('href', expectedLinkUrl('1'));
});

it('shows two numbered previous page links on third page', () => {
renderComponent(3, 3);
const nMinus1 = getAllLinks()[1];
const nMinus2 = getAllLinks()[2];
expect(nMinus1).not.toHaveClass('user-pagination__item--disabled');
expect(nMinus1).toHaveAttribute('href', expectedLinkUrl('1'));
expect(nMinus2).not.toHaveClass('user-pagination__item--disabled');
expect(nMinus2).toHaveAttribute('href', expectedLinkUrl('2'));
});

it('shows numbered link to first page on fourth page', () => {
renderComponent(4, 4);
const first = getAllLinks()[1];
expect(first).not.toHaveClass('user-pagination__item--disabled');
expect(first).toHaveAttribute('href', expectedLinkUrl('1'));
});

it('shows ellipses between first page link and link n-2 on fifth page', () => {
renderComponent(5, 10);
const prefixEllipses = screen.getAllByRole('label')[0];
expect(prefixEllipses).not.toHaveAttribute('href');
expect(prefixEllipses).toHaveTextContent('...');
expect(prefixEllipses).toHaveClass(
'user-pagination__item--prefix-ellipses'
);
});

it('shows one numbered next page link on page T-1', () => {
renderComponent(4, 5);
const allLinks = getAllLinks();
const next = allLinks[allLinks.length - 2];
expect(next).not.toHaveClass('user-pagination__item--disabled');
expect(next).toHaveAttribute('href', expectedLinkUrl('5'));
});

it('shows two numbered next page links on page T-2', () => {
renderComponent(3, 5);
const allLinks = getAllLinks();
const nPlus1 = allLinks[allLinks.length - 3];
const nPlus2 = allLinks[allLinks.length - 2];
expect(nPlus1).not.toHaveClass('user-pagination__item--disabled');
expect(nPlus1).toHaveAttribute('href', expectedLinkUrl('4'));
expect(nPlus2).not.toHaveClass('user-pagination__item--disabled');
expect(nPlus2).toHaveAttribute('href', expectedLinkUrl('5'));
});

it('shows last page link on page T-3', () => {
renderComponent(2, 5);
const allLinks = getAllLinks();
const last = allLinks[allLinks.length - 2];
expect(last).not.toHaveClass('user-pagination__item--disabled');
expect(last).toHaveAttribute('href', expectedLinkUrl('5'));
});

it('shows ellipses between last page link and link n+2 on page T-4', () => {
renderComponent(5, 10);
const postfixEllipses = screen.getAllByRole('label')[2];
expect(postfixEllipses).not.toHaveAttribute('href');
expect(postfixEllipses).toHaveTextContent('...');
expect(postfixEllipses).toHaveClass(
'user-pagination__item--suffix-ellipses'
);
});
});
104 changes: 104 additions & 0 deletions identity-admin/webapp/components/search/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useRouter } from 'next/router';
import { buildSearchUrl } from '../../utils/search-util';

type Props = {
currentPage: number;
pageCount: number;
};

const firstLinkClassName = 'user-pagination__item user-pagination__item--first';
const linkClassName = 'user-pagination__item';
const lastLinkClassName = 'user-pagination__item user-pagination__item--last';
const disabled = 'user-pagination__item--disabled';
const ellipses = 'user-pagination__item user-pagination__item--ellipses';
const maxPageLinks = 4;

const Pagination = ({ currentPage, pageCount }: Props): JSX.Element => {
const router = useRouter();
const { status, name, email, sort, sortDir } = router.query;

const pageUrl = (page: number): string => {
return buildSearchUrl(String(page), status, name, email, sort, sortDir);
};

const hasPrevious: boolean = currentPage > 1;
const hasNext: boolean = currentPage < pageCount;
const hasFirstPageLink: boolean = currentPage >= maxPageLinks;
const hasLastPageLink: boolean = currentPage < pageCount - maxPageLinks / 2;
const hasPrefixEllipses: boolean = currentPage > maxPageLinks;
const hasSuffixEllipses: boolean = currentPage <= pageCount - maxPageLinks;

return (
<div className="user-pagination">
{hasPrevious ? (
<a href={pageUrl(currentPage - 1)} className={firstLinkClassName}>
Previous
</a>
) : (
<span role="label" className={firstLinkClassName + ' ' + disabled}>
ajrussellaudio marked this conversation as resolved.
Show resolved Hide resolved
Previous
</span>
)}
{hasFirstPageLink && (
<a href={pageUrl(1)} className={linkClassName}>
1
</a>
)}
{hasPrefixEllipses && (
<span
role="label"
className={ellipses + ' user-pagination__item--prefix-ellipses'}
>
...
</span>
)}
{currentPage > 2 && (
<a href={pageUrl(currentPage - 2)} className={linkClassName}>
{currentPage - 2}
</a>
)}
{currentPage > 1 && (
<a href={pageUrl(currentPage - 1)} className={linkClassName}>
{currentPage - 1}
</a>
)}
<span role="label" className={linkClassName + ' ' + disabled}>
{currentPage}
</span>
{currentPage < pageCount && (
<a href={pageUrl(currentPage + 1)} className={linkClassName}>
{currentPage + 1}
</a>
)}
{currentPage + 1 < pageCount && (
<a href={pageUrl(currentPage + 2)} className={linkClassName}>
{currentPage + 2}
</a>
)}
{hasSuffixEllipses && (
<span
role="label"
className={ellipses + ' user-pagination__item--suffix-ellipses'}
>
...
</span>
)}
{hasLastPageLink && (
<a href={pageUrl(pageCount)} className={linkClassName}>
{pageCount}
</a>
)}
{hasNext ? (
<a href={pageUrl(currentPage + 1)} className={lastLinkClassName}>
Next
</a>
) : (
<span role="label" className={lastLinkClassName + ' ' + disabled}>
Next
</span>
)}
</div>
);
};

export default Pagination;
56 changes: 56 additions & 0 deletions identity-admin/webapp/components/search/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useRouter } from 'next/router';
import { buildSearchUrl } from '../../utils/search-util';

const SearchInput = (): JSX.Element => {
const router = useRouter();
const { status, name, email } = router.query;

const onChangeName = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
window.location.href = buildSearchUrl(
'1',
status,
event.currentTarget.nameField.value,
email
);
};

const onChangeEmail = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
window.location.href = buildSearchUrl(
'1',
status,
name,
event.currentTarget.emailField.value
);
};

return (
<>
<td className="user-list__filter-header user-list__first">
<form onSubmit={onChangeName} className="user-list__search-form">
<input
ajrussellaudio marked this conversation as resolved.
Show resolved Hide resolved
type="text"
name="nameField"
placeholder="Enter name"
defaultValue={name}
/>
<button type="submit">Search</button>
</form>
</td>
<td className="user-list__filter-header">
<form onSubmit={onChangeEmail} className="user-list__search-form">
<input
type="text"
name="emailField"
placeholder="Enter email"
defaultValue={email}
/>
<button type="submit">Search</button>
</form>
</td>
</>
);
};

export default SearchInput;
61 changes: 61 additions & 0 deletions identity-admin/webapp/components/search/Sorter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Sorter from './Sorter';
import { SortField } from '../../interfaces';
import { render, screen } from '@testing-library/react';
import { useRouter } from 'next/router';

const mockedRouter = useRouter as jest.Mock;

jest.mock('next/router');

const renderComponent = (sort: SortField) => {
return render(<Sorter fieldName={sort} />);
};

const mockQueryParams = (params: { sortDir?: string; sort: SortField }) => {
mockedRouter.mockImplementation(() => {
return {
query: {
status: 'active',
name: 'Bob',
email: '[email protected]',
...params,
},
};
});
};

const expectedUrl = (sort: SortField, sortDir: string) =>
ajrussellaudio marked this conversation as resolved.
Show resolved Hide resolved
'/?email=bob%40email.com&name=Bob&page=1&sort=' +
sort +
'&sortDir=' +
sortDir +
'&status=active';

describe('Sorter', () => {
it('shows down arrow if sort is applied to a different field', async () => {
mockQueryParams({ sortDir: '1', sort: SortField.Email });
renderComponent(SortField.Name);
expect(screen.getByText('▾')).toHaveAttribute(
'href',
expectedUrl(SortField.Name, '1')
);
});

it('shows down arrow if sort direction is asc', async () => {
mockQueryParams({ sortDir: '1', sort: SortField.Name });
renderComponent(SortField.Name);
expect(screen.getByText('▾')).toHaveAttribute(
'href',
expectedUrl(SortField.Name, '-1')
);
});

it('shows up arrow if sort dir is desc', async () => {
mockQueryParams({ sortDir: '-1', sort: SortField.Name });
renderComponent(SortField.Name);
expect(screen.getByText('▴')).toHaveAttribute(
'href',
expectedUrl(SortField.Name, '1')
);
});
});
45 changes: 45 additions & 0 deletions identity-admin/webapp/components/search/Sorter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useRouter } from 'next/router';
import { buildSearchUrl } from '../../utils/search-util';
import { SortField } from '../../interfaces';

type Props = {
fieldName: SortField;
};

const Sorter = ({ fieldName }: Props): JSX.Element => {
const router = useRouter();
const { status, name, email, sort, sortDir } = router.query;

const isCurrentlySortedField = () => {
return fieldName === sort;
};

const buildSortUrl = (): string => {
if (isCurrentlySortedField()) {
return buildSearchUrl(
'1',
status,
name,
email,
sort,
sortDir === '1' ? '-1' : '1'
);
}
return buildSearchUrl('1', status, name, email, fieldName, '1');
};

const sortSymbol = (): string => {
if (isCurrentlySortedField()) {
return sortDir === '1' ? '▾' : '▴';
}
return '▾';
};

return (
<a className="user-list__sorter" href={buildSortUrl()}>
{sortSymbol()}
</a>
);
};

export default Sorter;
Loading