Skip to content

Commit

Permalink
Merge pull request #4 from naatebarber/ui-pagination
Browse files Browse the repository at this point in the history
UI pagination
  • Loading branch information
naatebarber authored May 10, 2021
2 parents 144a018 + a379464 commit f57a52e
Show file tree
Hide file tree
Showing 25 changed files with 603 additions and 300 deletions.
52 changes: 51 additions & 1 deletion jsx/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Jupyterhub Admin Dashboard - React Variant

This repository contains current updates to the Jupyterhub Admin Dashboard service,
This repository contains current updates to the Jupyterhub Admin Dashboard,
reducing the complexity from a mass of templated HTML to a simple React web application.
This will integrate with Jupyterhub, speeding up client interactions while simplifying the
admin dashboard codebase.
Expand All @@ -12,3 +12,53 @@ admin dashboard codebase.
- `yarn lint`: Lints JSX with ESLint
- `yarn lint --fix`: Lints and fixes errors JSX with ESLint / formats with Prettier
- `yarn place`: Copies the transpiled React bundle to /share/jupyterhub/static/js/admin-react.js for use.

### Good To Know

Just some basics on how the React Admin app is built.

#### General build structure:

This app is written in JSX, and then transpiled into an ES5 bundle with Babel and Webpack. All JSX components are unit tested with a mixture of Jest and Enzyme and can be run both manually and per-commit. Most logic is separated into components under the `/src/components` directory, each directory containing a `.jsx`, `.test.jsx`, and sometimes a `.css` file. These components are all pulled together, given client-side routes, and connected to the Redux store in `/src/App.jsx` which serves as an entrypoint to the application.

#### Centralized state and data management with Redux:

The app use Redux throughout the components via the `useSelector` and `useDispatch` hooks to store and update user and group data from the API. With Redux, this data is available to any connected component. This means that if one component recieves new data, they all do.

#### API functions

All API functions used by the front end are packaged as a library of props within `/src/util/withAPI.js`. This keeps our web service logic separate from our presentational logic, allowing us to connect API functionality to our components at a high level and keep the code more modular. This connection specifically happens in `/src/App.jsx`, within the route assignments.

#### Pagination

Indicies of paginated user and group data is stored in a `page` variable in the query string, as well as the `user_page` / `group_page` state variables in Redux. This allows the app to maintain two sources of truth, as well as protect the admin user's place in the collection on page reload. Limit is constant at this point and is held in the Redux state.

On updates to the paginated data, the app can respond in one of two ways. If a user/group record is either added or deleted, the pagination will reset and data will be pulled back with no offset. Alternatively, if a record is modified, the offset will remain and the change will be shown.

Code examples:

```js
// Pagination limit is pulled in from Redux.
var limit = useSelector((state) => state.limit);

// Page query string is parsed and checked
var page = parseInt(new URLQuerySearch(props.location).get("page"));
page = isNaN(page) ? 0 : page;

// A slice is created representing the records to be returned
var slice = [page * limit, limit];

// A user's notebook server status was changed from stopped to running, user data is being refreshed from the slice.
startServer().then(() => {
updateUsers(...slice)
// After data is fetched, the Redux store is updated with the data and a copy of the page number.
.then((data) => dispatchPageChange(data, page));
});

// Alternatively, a new user was added, user data is being refreshed from offset 0.
addUser().then(() => {
updateUsers(0, limit)
// After data is fetched, the Redux store is updated with the data and asserts page 0.
.then((data) => dispatchPageChange(data, 0));
});
```
2 changes: 1 addition & 1 deletion jsx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"build": "yarn && webpack",
"hot": "webpack && webpack-dev-server",
"place": "cp -r build/admin-react.js ../share/jupyterhub/static/js/admin-react.js",
"test": "jest",
"test": "jest --verbose",
"snap": "jest --updateSnapshot",
"lint": "eslint --ext .jsx --ext .js src/",
"lint:fix": "eslint --ext .jsx --ext .js src/ --fix"
Expand Down
5 changes: 3 additions & 2 deletions jsx/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ const store = createStore(reducers, initialState);

const App = (props) => {
useEffect(() => {
jhapiRequest("/users", "GET")
let { limit, user_page, groups_page } = initialState;
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
.then((data) => store.dispatch({ type: "USER_DATA", value: data }))
.catch((err) => console.log(err));

jhapiRequest("/groups", "GET")
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET")
.then((data) => data.json())
.then((data) => store.dispatch({ type: "GROUPS_DATA", value: data }))
.catch((err) => console.log(err));
Expand Down
26 changes: 21 additions & 5 deletions jsx/src/Store.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,36 @@ import { combineReducers } from "redux";

export const initialState = {
user_data: undefined,
user_page: 0,
groups_data: undefined,
manage_groups_modal: false,
groups_page: 0,
limit: 50,
};

export const reducers = (state = initialState, action) => {
switch (action.type) {
// Updates the client user model data and stores the page
case "USER_PAGE":
return Object.assign({}, state, {
user_page: action.value.page,
user_data: action.value.data,
});

// Deprecated - doesn't store pagination values
case "USER_DATA":
return Object.assign({}, state, { user_data: action.value });
case "GROUPS_DATA":
return Object.assign({}, state, { groups_data: action.value });
case "TOGGLE_MANAGE_GROUPS_MODAL":

// Updates the client group model data and stores the page
case "GROUPS_PAGE":
return Object.assign({}, state, {
manage_groups_modal: !state.manage_groups_modal,
groups_page: action.value.page,
groups_data: action.value.data,
});

// Deprecated - doesn't store pagination values
case "GROUPS_DATA":
return Object.assign({}, state, { groups_data: action.value });

default:
return state;
}
Expand Down
26 changes: 15 additions & 11 deletions jsx/src/components/AddUser/AddUser.jsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { compose, withProps } from "recompose";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
import { jhapiRequest } from "../../util/jhapiUtil";

const AddUser = (props) => {
var [users, setUsers] = useState([]),
[admin, setAdmin] = useState(false);
[admin, setAdmin] = useState(false),
limit = useSelector((state) => state.limit);

var dispatch = useDispatch();

var dispatchUserData = (data) => {
var dispatchPageChange = (data, page) => {
dispatch({
type: "USER_DATA",
value: data,
type: "USER_PAGE",
value: {
data: data,
page: page,
},
});
};

var { addUsers, failRegexEvent, refreshUserData, history } = props;
var { addUsers, failRegexEvent, updateUsers, history } = props;

return (
<>
Expand Down Expand Up @@ -78,12 +82,12 @@ const AddUser = (props) => {
}

addUsers(filtered_users, admin)
.then(
refreshUserData()
.then((data) => dispatchUserData(data))
.then(() =>
updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err))
)
.then(() => history.push("/"))
.catch((err) => console.log(err));
}}
>
Expand All @@ -101,7 +105,7 @@ const AddUser = (props) => {
AddUser.propTypes = {
addUsers: PropTypes.func,
failRegexEvent: PropTypes.func,
refreshUserData: PropTypes.func,
updateUsers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
Expand Down
12 changes: 10 additions & 2 deletions jsx/src/components/AddUser/AddUser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";
import Enzyme, { mount } from "enzyme";
import AddUser from "./AddUser";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch } from "react-redux";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";

Expand All @@ -11,6 +11,7 @@ Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));

describe("AddUser Component: ", () => {
Expand All @@ -23,17 +24,24 @@ describe("AddUser Component: ", () => {
<AddUser
addUsers={callbackSpy}
failRegexEvent={callbackSpy}
refreshUserData={callbackSpy}
updateUsers={callbackSpy}
history={{ push: (a) => {} }}
/>
</HashRouter>
</Provider>
);

var mockAppState = () => ({
limit: 3,
});

beforeEach(() => {
useDispatch.mockImplementation((callback) => {
return () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});

afterEach(() => {
Expand Down
24 changes: 13 additions & 11 deletions jsx/src/components/CreateGroup/CreateGroup.jsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { compose, withProps } from "recompose";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
import { jhapiRequest } from "../../util/jhapiUtil";

const CreateGroup = (props) => {
var [groupName, setGroupName] = useState("");
var [groupName, setGroupName] = useState(""),
limit = useSelector((state) => state.limit);

var dispatch = useDispatch();

var dispatchGroupsData = (data) => {
var dispatchPageUpdate = (data, page) => {
dispatch({
type: "GROUPS_DATA",
value: data,
value: {
data: data,
page: page,
},
});
};

var { createGroup, refreshGroupsData, history } = props;
var { createGroup, updateGroups, history } = props;

return (
<>
Expand Down Expand Up @@ -53,11 +55,11 @@ const CreateGroup = (props) => {
onClick={() => {
createGroup(groupName)
.then(
refreshGroupsData()
.then((data) => dispatchGroupsData(data))
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(history.push("/groups"))
.catch((err) => console.log(err))
)
.then(history.push("/groups"))
.catch((err) => console.log(err));
}}
>
Expand All @@ -74,7 +76,7 @@ const CreateGroup = (props) => {

CreateGroup.propTypes = {
createGroup: PropTypes.func,
refreshGroupsData: PropTypes.func,
updateGroups: PropTypes.func,
failRegexEvent: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
Expand Down
16 changes: 12 additions & 4 deletions jsx/src/components/CreateGroup/CreateGroup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";
import Enzyme, { mount } from "enzyme";
import CreateGroup from "./CreateGroup";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
import { Provider, useDispatch } from "react-redux";
import { Provider, useDispatch, useSelector } from "react-redux";
import { createStore } from "redux";
import { HashRouter } from "react-router-dom";

Expand All @@ -11,6 +11,7 @@ Enzyme.configure({ adapter: new Adapter() });
jest.mock("react-redux", () => ({
...jest.requireActual("react-redux"),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));

describe("CreateGroup Component: ", () => {
Expand All @@ -22,16 +23,23 @@ describe("CreateGroup Component: ", () => {
<HashRouter>
<CreateGroup
createGroup={callbackSpy}
refreshGroupsData={callbackSpy}
updateGroups={callbackSpy}
history={{ push: () => {} }}
/>
</HashRouter>
</Provider>
);

var mockAppState = () => ({
limit: 3,
});

beforeEach(() => {
useDispatch.mockImplementation((callback) => {
return () => {};
return () => () => {};
});
useSelector.mockImplementation((callback) => {
return callback(mockAppState());
});
});

Expand All @@ -52,6 +60,6 @@ describe("CreateGroup Component: ", () => {
input.simulate("change", { target: { value: "" } });
submit.simulate("click");
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
expect(callbackSpy).toHaveBeenNthCalledWith(2);
expect(callbackSpy).toHaveBeenNthCalledWith(2, 0, 3);
});
});
Loading

0 comments on commit f57a52e

Please sign in to comment.