start implementation of pagination for users

This commit is contained in:
Sebastian Sdorra
2018-07-26 15:30:28 +02:00
parent 0d551de147
commit ac32b88256
11 changed files with 508 additions and 32 deletions

View File

@@ -32,5 +32,9 @@
"repositories": "Repositories", "repositories": "Repositories",
"users": "Users", "users": "Users",
"logout": "Logout" "logout": "Logout"
},
"paginator": {
"next": "Next",
"previous": "Previous"
} }
} }

View File

@@ -9,8 +9,10 @@
}, },
"users": { "users": {
"title": "Users", "title": "Users",
"subtitle": "Create, read, update and delete users", "subtitle": "Create, read, update and delete users"
"add-button": "Add User" },
"create-user-button": {
"label": "Create"
}, },
"delete-user-button": { "delete-user-button": {
"label": "Delete", "label": "Delete",

View File

@@ -0,0 +1,119 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { PagedCollection } from "../types/Collection";
import { Button } from "./buttons";
type Props = {
collection: PagedCollection,
onPageChange: string => void,
t: string => string
};
class Paginator extends React.Component<Props> {
isLinkUnavailable(linkType: string) {
return !this.props.collection || !this.props.collection._links[linkType];
}
createAction = (linkType: string) => () => {
const { collection, onPageChange } = this.props;
const link = collection._links[linkType].href;
onPageChange(link);
};
renderFirstButton() {
return this.renderPageButton(1, "first");
}
renderPreviousButton() {
return this.renderButton(
"pagination-previous",
"paginator.previous",
"prev"
);
}
renderNextButton() {
return this.renderButton("pagination-next", "paginator.next", "next");
}
renderLastButton() {
const { collection } = this.props;
return this.renderPageButton(collection.pageTotal, "last");
}
renderPageButton(page: number, linkType: string) {
return this.renderButton("pagination-link", page.toString(), linkType);
}
renderButton(className: string, label: string, linkType: string) {
const { t } = this.props;
return (
<Button
className={className}
label={t(label)}
disabled={this.isLinkUnavailable(linkType)}
action={this.createAction(linkType)}
/>
);
}
seperator() {
return <span className="pagination-ellipsis">&hellip;</span>;
}
currentPage(page: number) {
return (
<Button
className="pagination-link is-current"
label={page}
disabled={true}
/>
);
}
pageLinks() {
const { collection } = this.props;
const links = [];
const page = collection.page + 1;
const pageTotal = collection.pageTotal;
if (page > 1) {
links.push(this.renderFirstButton());
}
if (page > 3) {
links.push(this.seperator());
}
if (page > 2) {
links.push(this.renderPageButton(page - 1, "prev"));
}
links.push(this.currentPage(page));
if (page + 1 < pageTotal) {
links.push(this.renderPageButton(page + 1, "next"));
links.push(this.seperator());
}
if (page < pageTotal) {
links.push(this.renderLastButton());
}
return links;
}
render() {
return (
<nav className="pagination is-centered" aria-label="pagination">
{this.renderPreviousButton()}
{this.renderNextButton()}
<ul className="pagination-list">
{this.pageLinks().map((link, index) => {
return <li key={index}>{link}</li>;
})}
</ul>
</nav>
);
}
}
export default translate("commons")(Paginator);

View File

@@ -0,0 +1,227 @@
// @flow
import React from "react";
import type { PagedCollection } from "../types/Collection";
import { mount, shallow } from "enzyme";
import "../tests/enzyme";
import "../tests/i18n";
import Paginator from "./Paginator";
describe("paginator rendering tests", () => {
const dummyLink = {
href: "https://dummy"
};
it("should render all buttons but disabled, without links", () => {
const collection = {
page: 10,
pageTotal: 20,
_links: {}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(7);
for (let button of buttons) {
expect(button.props.disabled).toBeTruthy();
}
});
it("should render buttons for first page", () => {
const collection = {
page: 0,
pageTotal: 148,
_links: {
first: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(5);
// previous button
expect(buttons.get(0).props.disabled).toBeTruthy();
// last button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeTruthy();
expect(firstButton.label).toBe(1);
// next button
const nextButton = buttons.get(3).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("2");
// last button
const lastButton = buttons.get(4).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
it("should render buttons for second page", () => {
const collection = {
page: 1,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(6);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// last button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
// current button
const currentButton = buttons.get(3).props;
expect(currentButton.disabled).toBeTruthy();
expect(currentButton.label).toBe(2);
// next button
const nextButton = buttons.get(4).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("3");
// last button
const lastButton = buttons.get(5).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
it("should render buttons for last page", () => {
const collection = {
page: 147,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(5);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// last button
expect(buttons.get(1).props.disabled).toBeTruthy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
// next button
const nextButton = buttons.get(3).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("147");
// last button
const lastButton = buttons.get(4).props;
expect(lastButton.disabled).toBeTruthy();
expect(lastButton.label).toBe(148);
});
it("should render buttons for penultimate page", () => {
const collection = {
page: 146,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(6);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// last button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
const currentButton = buttons.get(3).props;
expect(currentButton.disabled).toBeFalsy();
expect(currentButton.label).toBe("146");
// current button
const nextButton = buttons.get(4).props;
expect(nextButton.disabled).toBeTruthy();
expect(nextButton.label).toBe(147);
// last button
const lastButton = buttons.get(5).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
it("should render buttons for a page in the middle", () => {
const collection = {
page: 41,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(7);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// next button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
// previous Button
const previousButton = buttons.get(3).props;
expect(previousButton.disabled).toBeFalsy();
expect(previousButton.label).toBe("41");
// current button
const currentButton = buttons.get(4).props;
expect(currentButton.disabled).toBeTruthy();
expect(currentButton.label).toBe(42);
// next button
const nextButton = buttons.get(5).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("43");
// last button
const lastButton = buttons.get(6).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
});

View File

@@ -9,7 +9,8 @@ export type ButtonProps = {
disabled?: boolean, disabled?: boolean,
action?: () => void, action?: () => void,
link?: string, link?: string,
fullWidth?: boolean fullWidth?: boolean,
className?: string
}; };
type Props = ButtonProps & { type Props = ButtonProps & {
@@ -17,8 +18,20 @@ type Props = ButtonProps & {
}; };
class Button extends React.Component<Props> { class Button extends React.Component<Props> {
static defaultProps = {
type: "default"
};
renderButton = () => { renderButton = () => {
const { label, loading, disabled, type, action, fullWidth } = this.props; const {
label,
loading,
disabled,
type,
action,
fullWidth,
className
} = this.props;
const loadingClass = loading ? "is-loading" : ""; const loadingClass = loading ? "is-loading" : "";
const fullWidthClass = fullWidth ? "is-fullwidth" : ""; const fullWidthClass = fullWidth ? "is-fullwidth" : "";
return ( return (
@@ -29,7 +42,8 @@ class Button extends React.Component<Props> {
"button", "button",
"is-" + type, "is-" + type,
loadingClass, loadingClass,
fullWidthClass fullWidthClass,
className
)} )}
> >
{label} {label}

View File

@@ -42,6 +42,12 @@ class Main extends React.Component<Props> {
path="/users/add" path="/users/add"
component={AddUser} component={AddUser}
/> />
<ProtectedRoute
exact
path="/users/:page"
component={Users}
authenticated={authenticated}
/>
<ProtectedRoute <ProtectedRoute
authenticated={authenticated} authenticated={authenticated}
path="/user/:name" path="/user/:name"

View File

@@ -0,0 +1,12 @@
// @flow
import type { Links } from "./hal";
export type Collection = {
_embedded: Object,
_links: Links
};
export type PagedCollection = Collection & {
page: number,
pageTotal: number
};

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import { AddButton } from "../../../components/buttons";
import classNames from "classnames";
const styles = {
spacing: {
margin: "1em 0 0 1em"
}
};
type Props = {
t: string => string,
classes: any
};
class CreateUserButton extends React.Component<Props> {
render() {
const { classes, t } = this.props;
return (
<div className={classNames("is-pulled-right", classes.spacing)}>
<AddButton label={t("create-user-button.label")} link="/users/add" />
</div>
);
}
}
export default translate("users")(injectSheet(styles)(CreateUserButton));

View File

@@ -1,31 +1,61 @@
// @flow // @flow
import React from "react"; import React from "react";
import type { History } from "history";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import { fetchUsers, getUsersFromState } from "../modules/users"; import {
fetchUsersByPage,
fetchUsersByLink,
getUsersFromState,
selectList
} from "../modules/users";
import { Page } from "../../components/layout"; import { Page } from "../../components/layout";
import { UserTable } from "./../components/table"; import { UserTable } from "./../components/table";
import type { User } from "../types/User"; import type { User } from "../types/User";
import { AddButton } from "../../components/buttons"; import { AddButton } from "../../components/buttons";
import type { UserEntry } from "../types/UserEntry"; import type { UserEntry } from "../types/UserEntry";
import type { PagedCollection } from "../../types/Collection";
import Paginator from "../../components/Paginator";
import CreateUserButton from "../components/buttons/CreateUserButton";
type Props = { type Props = {
loading?: boolean, loading?: boolean,
error: Error, error: Error,
t: string => string, t: string => string,
userEntries: Array<UserEntry>, userEntries: Array<UserEntry>,
fetchUsers: () => void, fetchUsersByPage: (page: number) => void,
canAddUsers: boolean fetchUsersByLink: (link: string) => void,
canAddUsers: boolean,
list?: PagedCollection,
history: History,
match: any,
page: number
}; };
class Users extends React.Component<Props, User> { class Users extends React.Component<Props, User> {
componentDidMount() { componentDidMount() {
this.props.fetchUsers(); this.props.fetchUsersByPage(this.props.page);
} }
onPageChange = (link: string) => {
this.props.fetchUsersByLink(link);
};
componentDidUpdate = (prevProps: Props) => {
const { page, list } = this.props;
if (list) {
// backend starts with 0
const statePage: number = list.page + 1;
if (page !== statePage) {
this.props.history.push(`/users/${statePage}`);
}
}
};
render() { render() {
const { userEntries, loading, t, error } = this.props; const { userEntries, list, loading, t, error } = this.props;
return ( return (
<Page <Page
title={t("users.title")} title={t("users.title")}
@@ -34,26 +64,36 @@ class Users extends React.Component<Props, User> {
error={error} error={error}
> >
<UserTable entries={userEntries} /> <UserTable entries={userEntries} />
{this.renderPaginator()}
{this.renderAddButton()} {this.renderAddButton()}
</Page> </Page>
); );
} }
renderPaginator() {
const { list } = this.props;
if (list) {
return <Paginator collection={list} onPageChange={this.onPageChange} />;
}
return null;
}
renderAddButton() { renderAddButton() {
const { canAddUsers, t } = this.props; if (this.props.canAddUsers) {
if (canAddUsers) { return <CreateUserButton />;
return (
<div>
<AddButton label={t("users.add-button")} link="/users/add" />
</div>
);
} else { } else {
return; return;
} }
} }
} }
const mapStateToProps = state => { const mapStateToProps = (state, ownProps) => {
let page = ownProps.match.params.page;
if (page) {
page = parseInt(page, 10);
} else {
page = 1;
}
const userEntries = getUsersFromState(state); const userEntries = getUsersFromState(state);
let error = null; let error = null;
let loading = false; let loading = false;
@@ -63,18 +103,24 @@ const mapStateToProps = state => {
canAddUsers = state.users.list.userCreatePermission; canAddUsers = state.users.list.userCreatePermission;
loading = state.users.list.loading; loading = state.users.list.loading;
} }
const list = selectList(state);
return { return {
userEntries, userEntries,
error, error,
loading, loading,
canAddUsers canAddUsers,
list,
page
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = dispatch => {
return { return {
fetchUsers: () => { fetchUsersByPage: (page: number) => {
dispatch(fetchUsers()); dispatch(fetchUsersByPage(page));
},
fetchUsersByLink: (link: string) => {
dispatch(fetchUsersByLink(link));
} }
}; };
}; };

View File

@@ -32,18 +32,20 @@ const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
//fetch users //fetch users
export function fetchUsers() { export function fetchUsers() {
return fetchUsersByLink(USERS_URL);
}
export function fetchUsersByPage(page: number) {
// backend start counting by 0
return fetchUsersByLink(USERS_URL + "?page=" + (page - 1));
}
export function fetchUsersByLink(link: string) {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(fetchUsersPending()); dispatch(fetchUsersPending());
return apiClient return apiClient
.get(USERS_URL) .get(link)
.then(response => { .then(response => response.json())
return response;
})
.then(response => {
if (response.ok) {
return response.json();
}
})
.then(data => { .then(data => {
dispatch(fetchUsersSuccess(data)); dispatch(fetchUsersSuccess(data));
}) })
@@ -345,7 +347,10 @@ export default function reducer(state: any = {}, action: any = {}) {
error: null, error: null,
entries: userNames, entries: userNames,
loading: false, loading: false,
userCreatePermission: action.payload._links.create ? true : false userCreatePermission: action.payload._links.create ? true : false,
page: action.payload.page,
pageTotal: action.payload.pageTotal,
_links: action.payload._links
}, },
byNames byNames
}; };
@@ -450,3 +455,11 @@ export default function reducer(state: any = {}, action: any = {}) {
return state; return state;
} }
} }
// selectors
export const selectList = (state: Object) => {
if (state.users && state.users.list && state.users.list._links) {
return state.users.list;
}
};

View File

@@ -359,7 +359,10 @@ describe("users reducer", () => {
entries: ["zaphod", "ford"], entries: ["zaphod", "ford"],
error: null, error: null,
loading: false, loading: false,
userCreatePermission: true userCreatePermission: true,
page: 0,
pageTotal: 1,
_links: responseBody._links
}); });
expect(newState.byNames).toEqual({ expect(newState.byNames).toEqual({