Merged in feature/ui_users_paging (pull request #53)

Feature/ui users paging
This commit is contained in:
Philipp Czora
2018-07-30 07:19:59 +00:00
11 changed files with 659 additions and 68 deletions

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
//@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() {
const { t } = this.props;
return this.renderButton(
"pagination-previous",
t("paginator.previous"),
"prev"
);
}
renderNextButton() {
const { t } = this.props;
return this.renderButton("pagination-next", t("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) {
return (
<Button
className={className}
label={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,253 @@
// @flow
import React from "react";
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");
});
it("should call the function with the last previous url", () => {
const collection = {
page: 41,
pageTotal: 148,
_links: {
first: dummyLink,
prev: {
href: "https://www.scm-manager.org"
},
next: dummyLink,
last: dummyLink
}
};
let urlToOpen;
const callMe = (url: string) => {
urlToOpen = url;
};
const paginator = mount(
<Paginator collection={collection} onPageChange={callMe} />
);
paginator.find("Button.pagination-previous").simulate("click");
expect(urlToOpen).toBe("https://www.scm-manager.org");
});
});

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
// @flow
import type { Links } from "./hal";
export type Collection = {
_embedded: Object,
_links: Links
};
export type PagedCollection = Collection & {
page: number,
pageTotal: number
};
export type PageCollectionStateSlice = {
entry?: PagedCollection,
error?: Error,
loading?: boolean
};

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,80 +1,127 @@
// @flow
import React from "react";
import type { History } from "history";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { fetchUsers, getUsersFromState } from "../modules/users";
import {
fetchUsersByPage,
fetchUsersByLink,
getUsersFromState,
selectListAsCollection,
isPermittedToCreateUsers
} from "../modules/users";
import { Page } from "../../components/layout";
import { UserTable } from "./../components/table";
import type { User } from "../types/User";
import { AddButton } from "../../components/buttons";
import type { UserEntry } from "../types/UserEntry";
import type { PageCollectionStateSlice } from "../../types/Collection";
import Paginator from "../../components/Paginator";
import CreateUserButton from "../components/buttons/CreateUserButton";
type Props = {
loading?: boolean,
error: Error,
userEntries: UserEntry[],
canAddUsers: boolean,
list: PageCollectionStateSlice,
page: number,
// context objects
t: string => string,
userEntries: Array<UserEntry>,
fetchUsers: () => void,
canAddUsers: boolean
history: History,
// dispatch functions
fetchUsersByPage: (page: number) => void,
fetchUsersByLink: (link: string) => void
};
class Users extends React.Component<Props, User> {
class Users extends React.Component<Props> {
componentDidMount() {
this.props.fetchUsers();
this.props.fetchUsersByPage(this.props.page);
}
onPageChange = (link: string) => {
this.props.fetchUsersByLink(link);
};
/**
* reflect page transitions in the uri
*/
componentDidUpdate = (prevProps: Props) => {
const { page, list } = this.props;
if (list.entry) {
// backend starts paging by 0
const statePage: number = list.entry.page + 1;
if (page !== statePage) {
this.props.history.push(`/users/${statePage}`);
}
}
};
render() {
const { userEntries, loading, t, error } = this.props;
const { userEntries, list, t } = this.props;
return (
<Page
title={t("users.title")}
subtitle={t("users.subtitle")}
loading={loading || !userEntries}
error={error}
loading={list.loading || !userEntries}
error={list.error}
>
<UserTable entries={userEntries} />
{this.renderAddButton()}
{this.renderPaginator()}
{this.renderCreateButton()}
</Page>
);
}
renderAddButton() {
const { canAddUsers, t } = this.props;
if (canAddUsers) {
renderPaginator() {
const { list } = this.props;
if (list.entry) {
return (
<div>
<AddButton label={t("users.add-button")} link="/users/add" />
</div>
<Paginator collection={list.entry} onPageChange={this.onPageChange} />
);
}
return null;
}
renderCreateButton() {
if (this.props.canAddUsers) {
return <CreateUserButton />;
} else {
return;
}
}
}
const mapStateToProps = state => {
const userEntries = getUsersFromState(state);
let error = null;
let loading = false;
let canAddUsers = false;
if (state.users && state.users.list) {
error = state.users.list.error;
canAddUsers = state.users.list.userCreatePermission;
loading = state.users.list.loading;
const getPageFromProps = props => {
let page = props.match.params.page;
if (page) {
page = parseInt(page, 10);
} else {
page = 1;
}
return page;
};
const mapStateToProps = (state, ownProps) => {
const page = getPageFromProps(ownProps);
const userEntries = getUsersFromState(state);
const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state);
return {
userEntries,
error,
loading,
canAddUsers
canAddUsers,
list,
page
};
};
const mapDispatchToProps = dispatch => {
return {
fetchUsers: () => {
dispatch(fetchUsers());
fetchUsersByPage: (page: number) => {
dispatch(fetchUsersByPage(page));
},
fetchUsersByLink: (link: string) => {
dispatch(fetchUsersByLink(link));
}
};
};

View File

@@ -4,6 +4,7 @@ import type { User } from "../types/User";
import type { UserEntry } from "../types/UserEntry";
import { combineReducers, Dispatch } from "redux";
import type { Action } from "../../types/Action";
import type { PageCollectionStateSlice } from "../../types/Collection";
export const FETCH_USERS_PENDING = "scm/users/FETCH_USERS_PENDING";
export const FETCH_USERS_SUCCESS = "scm/users/FETCH_USERS_SUCCESS";
@@ -35,18 +36,20 @@ const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
//fetch users
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) {
dispatch(fetchUsersPending());
return apiClient
.get(USERS_URL)
.then(response => {
return response;
})
.then(response => {
if (response.ok) {
return response.json();
}
})
.get(link)
.then(response => response.json())
.then(data => {
dispatch(fetchUsersSuccess(data));
})
@@ -348,7 +351,12 @@ function listReducer(state: any = {}, action: any = {}) {
error: null,
entries: userNames,
loading: false,
userCreatePermission: action.payload._links.create ? true : false
entry: {
userCreatePermission: action.payload._links.create ? true : false,
page: action.payload.page,
pageTotal: action.payload.pageTotal,
_links: action.payload._links
}
};
case FETCH_USERS_FAILURE:
return {
@@ -439,6 +447,36 @@ function byNamesReducer(state: any = {}, action: any = {}) {
}
}
// selectors
const selectList = (state: Object) => {
if (state.users && state.users.list) {
return state.users.list;
}
return {};
};
const selectListEntry = (state: Object) => {
const list = selectList(state);
if (list.entry) {
return list.entry;
}
return {};
};
export const selectListAsCollection = (
state: Object
): PageCollectionStateSlice => {
return selectList(state);
};
export const isPermittedToCreateUsers = (state: Object): boolean => {
const permission = selectListEntry(state).userCreatePermission;
if (permission) {
return true;
}
return false;
};
function createReducer(state: any = {}, action: any = {}) {
switch (action.type) {
case CREATE_USER_PENDING:

View File

@@ -33,6 +33,8 @@ import reducer, {
fetchUsersPending,
fetchUsersSuccess,
fetchUserSuccess,
selectListAsCollection,
isPermittedToCreateUsers,
MODIFY_USER_FAILURE,
MODIFY_USER_PENDING,
MODIFY_USER_SUCCESS,
@@ -336,7 +338,12 @@ describe("users reducer", () => {
entries: ["zaphod", "ford"],
error: null,
loading: false,
userCreatePermission: true
entry: {
userCreatePermission: true,
page: 0,
pageTotal: 1,
_links: responseBody._links
}
});
expect(newState.byNames).toEqual({
@@ -348,27 +355,27 @@ describe("users reducer", () => {
}
});
it("should set error when fetching users failed", () => {
const oldState = {
list: {
loading: true
}
};
expect(newState.list.entry.userCreatePermission).toBeTruthy();
});
const error = new Error("kaputt");
it("should set error when fetching users failed", () => {
const oldState = {
list: {
loading: true
}
};
const newState = reducer(oldState, fetchUsersFailure("url.com", error));
expect(newState.list.loading).toBeFalsy();
expect(newState.list.error).toEqual(error);
});
const error = new Error("kaputt");
it("should set userCreatePermission to true if update link is present", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
const newState = reducer(oldState, fetchUsersFailure("url.com", error));
expect(newState.list.loading).toBeFalsy();
expect(newState.list.error).toEqual(error);
});
expect(newState.list.userCreatePermission).toBeTruthy();
});
it("should set userCreatePermission to true if update link is present", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
expect(newState.list.userCreatePermission).toBeTruthy();
expect(newState.list.entry.userCreatePermission).toBeTruthy();
});
it("should not replace whole byNames map when fetching users", () => {
@@ -385,7 +392,7 @@ describe("users reducer", () => {
expect(newState.byNames["ford"]).toBeDefined();
});
test("should update state correctly according to DELETE_USER_PENDING action", () => {
it("should update state correctly according to DELETE_USER_PENDING action", () => {
const state = {
byNames: {
zaphod: {
@@ -479,8 +486,17 @@ describe("users reducer", () => {
const newState = reducer(state, deleteUserSuccess(userFord));
expect(newState.byNames["zaphod"]).toBeDefined();
expect(newState.list.entries).toEqual(["zaphod"]);
expect(newState.byNames["ford"]).toBeFalsy();
expect(newState.list.entries).toEqual(["zaphod"]);
});
it("should set userCreatePermission to true if create link is present", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
expect(newState.list.entry.userCreatePermission).toBeTruthy();
expect(newState.list.entries).toEqual(["zaphod", "ford"]);
expect(newState.byNames["ford"]).toBeTruthy();
expect(newState.byNames["zaphod"]).toBeTruthy();
});
it("should update state correctly according to CREATE_USER_PENDING action", () => {
@@ -614,3 +630,46 @@ describe("users reducer", () => {
expect(newState.byNames["ford"].entry).toBeFalsy();
});
});
describe("selector tests", () => {
it("should return an empty object", () => {
expect(selectListAsCollection({})).toEqual({});
expect(selectListAsCollection({ users: { a: "a" } })).toEqual({});
});
it("should return a state slice collection", () => {
const state = {
users: {
list: {
loading: false
}
}
};
expect(selectListAsCollection(state)).toEqual({ loading: false });
});
it("should return false", () => {
expect(isPermittedToCreateUsers({})).toBe(false);
expect(isPermittedToCreateUsers({ users: { list: { entry: {} } } })).toBe(
false
);
expect(
isPermittedToCreateUsers({
users: { list: { entry: { userCreatePermission: false } } }
})
).toBe(false);
});
it("should return true", () => {
const state = {
users: {
list: {
entry: {
userCreatePermission: true
}
}
}
};
expect(isPermittedToCreateUsers(state)).toBe(true);
});
});