Add UI pagination, update Redux and API service lib

This commit is contained in:
Nathan Barber
2021-05-05 18:41:48 -04:00
parent 5e2ca7bcff
commit 0439a0d274
14 changed files with 334 additions and 149 deletions

View File

@@ -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));
});
```

View File

@@ -5,25 +5,33 @@ export const initialState = {
user_page: 0,
groups_data: undefined,
groups_page: 0,
limit: 50,
manage_groups_modal: false,
limit: 3,
};
export const reducers = (state = initialState, action) => {
switch (action.type) {
case "USER_DATA":
return Object.assign({}, state, { user_data: action.value });
// 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 });
// Updates the client group model data and stores the page
case "GROUPS_PAGE":
return Object.assign({}, state, {
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 });
case "TOGGLE_MANAGE_GROUPS_MODAL":
return Object.assign({}, state, {
manage_groups_modal: !state.manage_groups_modal,
});
default:
return state;
}

1
jsx/src/TODO Normal file
View File

@@ -0,0 +1 @@
- When changing route with nothing edited, pass user_data / group_data through location to maintain spot

View File

@@ -1,5 +1,5 @@
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";
@@ -7,18 +7,22 @@ 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 (
<>
@@ -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));
}}
>
@@ -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,
}),

View File

@@ -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 (
<>
@@ -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));
}}
>
@@ -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,

View File

@@ -1,15 +1,20 @@
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
const EditUser = (props) => {
var 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,
},
});
};
@@ -18,7 +23,7 @@ const EditUser = (props) => {
deleteUser,
failRegexEvent,
noChangeEvent,
refreshUserData,
updateUsers,
history,
} = props;
@@ -70,9 +75,9 @@ const EditUser = (props) => {
onClick={() => {
deleteUser(username)
.then((data) => {
history.push("/");
refreshUserData()
.then((data) => dispatchUserData(data))
updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err));
})
.catch((err) => console.log(err));
@@ -106,9 +111,9 @@ const EditUser = (props) => {
admin
)
.then((data) => {
history.push("/");
refreshUserData()
.then((data) => dispatchUserData(data))
updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err));
})
.catch((err) => {});
@@ -119,9 +124,9 @@ const EditUser = (props) => {
} else {
editUser(username, username, admin)
.then((data) => {
history.push("/");
refreshUserData()
.then((data) => dispatchUserData(data))
updateUsers(0, limit)
.then((data) => dispatchPageChange(data, 0))
.then(() => history.push("/"))
.catch((err) => console.log(err));
})
.catch((err) => {});
@@ -153,7 +158,7 @@ EditUser.propTypes = {
deleteUser: PropTypes.func,
failRegexEvent: PropTypes.func,
noChangeEvent: PropTypes.func,
refreshUserData: PropTypes.func,
updateUsers: PropTypes.func,
};
export default EditUser;

View File

@@ -10,14 +10,18 @@ const GroupEdit = (props) => {
var [selected, setSelected] = useState([]),
[changed, setChanged] = useState(false),
[added, setAdded] = useState(undefined),
[removed, setRemoved] = useState(undefined);
[removed, setRemoved] = useState(undefined),
limit = useSelector((state) => state.limit);
var dispatch = useDispatch();
const dispatchGroupsData = (data) => {
const dispatchPageUpdate = (data, page) => {
dispatch({
type: "GROUPS_DATA",
value: data,
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
@@ -25,7 +29,7 @@ const GroupEdit = (props) => {
addToGroup,
removeFromGroup,
deleteGroup,
refreshGroupsData,
updateGroups,
history,
location,
} = props;
@@ -88,10 +92,12 @@ const GroupEdit = (props) => {
);
Promise.all(promiseQueue)
.then((e) => callback())
.then((e) => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
})
.catch((err) => console.log(err));
history.push("/groups");
}}
>
Apply
@@ -103,10 +109,11 @@ const GroupEdit = (props) => {
onClick={() => {
var groupName = group_data.name;
deleteGroup(groupName)
.then(
refreshGroupsData().then((data) => dispatchGroupsData(data))
)
.then(history.push("/groups"))
.then((e) => {
updateGroups(0, limit)
.then((data) => dispatchPageUpdate(data, 0))
.then(() => history.push("/groups"));
})
.catch((err) => console.log(err));
}}
>
@@ -134,7 +141,7 @@ GroupEdit.propTypes = {
addToGroup: PropTypes.func,
removeFromGroup: PropTypes.func,
deleteGroup: PropTypes.func,
refreshGroupsData: PropTypes.func,
updateGroups: PropTypes.func,
};
export default GroupEdit;

View File

@@ -4,32 +4,41 @@ import { compose, withProps } from "recompose";
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
import { jhapiRequest } from "../../util/jhapiUtil";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const Groups = (props) => {
var user_data = useSelector((state) => state.user_data),
groups_data = useSelector((state) => state.groups_data),
dispatch = useDispatch();
groups_page = useSelector((state) => state.groups_page),
user_page = useSelector((state) => state.user_page),
limit = useSelector((state) => state.limit),
dispatch = useDispatch(),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
var { refreshGroupsData, refreshUserData, history } = props;
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
var { updateGroups, history } = props;
if (!groups_data || !user_data) {
return <div></div>;
}
const dispatchGroupsData = (data) => {
const dispatchPageChange = (data, page) => {
dispatch({
type: "GROUPS_DATA",
value: data,
type: "GROUPS_PAGE",
value: {
data: data,
page: page,
},
});
};
const dispatchUserData = (data) => {
dispatch({
type: "USER_DATA",
value: data,
if (groups_page != page) {
updateGroups(...slice).then((data) => {
dispatchPageChange(data, page);
});
};
}
return (
<div className="container">
@@ -40,37 +49,39 @@ const Groups = (props) => {
<h4>Groups</h4>
</div>
<div className="panel-body">
<ul className="list-group">
{groups_data.length > 0 ? (
groups_data.map((e, i) => (
<div key={"group-edit" + i} className="group-edit-link">
<h4>
<li className="list-group-item" key={"group-item" + i}>
<span className="badge badge-pill badge-success">
{e.users.length + " users"}
</span>
<Link
to={{
pathname: "/group-edit",
state: {
group_data: e,
user_data: user_data,
callback: () => {
refreshGroupsData()
.then((data) => dispatchGroupsData(data))
.catch((err) => console.log(err));
refreshUserData()
.then((data) => dispatchUserData(data))
.catch((err) => console.log(err));
},
},
}}
>
{e.name}
</Link>
</h4>
</div>
</li>
))
) : (
<div>
<h4>no groups created...</h4>
</div>
)}
</ul>
<PaginationFooter
endpoint="/groups"
page={page}
limit={limit}
numOffset={slice[0]}
numElements={groups_data.length}
/>
</div>
<div className="panel-footer">
<button className="btn btn-light adjacent-span-spacing">
@@ -95,11 +106,14 @@ const Groups = (props) => {
Groups.propTypes = {
user_data: PropTypes.array,
groups_data: PropTypes.array,
refreshUserData: PropTypes.func,
refreshGroupsData: PropTypes.func,
updateUsers: PropTypes.func,
updateGroups: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
location: PropTypes.shape({
search: PropTypes.string,
}),
};
export default Groups;

View File

@@ -0,0 +1,50 @@
import React from "react";
import { Link } from "react-router-dom";
import PropTypes from "prop-types";
import "./pagination-footer.css";
const PaginationFooter = (props) => {
let { endpoint, page, limit, numOffset, numElements } = props;
return (
<div className="pagination-footer">
<p>
Displaying {numOffset}-{numOffset + numElements}
<br></br>
<br></br>
{page >= 1 ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page - 1}`}>
<span className="active-pagination">Previous</span>
</Link>
</button>
) : (
<button className="btn btn-sm btn-light spaced">
<span className="inactive-pagination">Previous</span>
</button>
)}
{numElements >= limit ? (
<button className="btn btn-sm btn-light spaced">
<Link to={`${endpoint}?page=${page + 1}`}>
<span className="active-pagination">Next</span>
</Link>
</button>
) : (
<button className="btn btn-sm btn-light spaced">
<span className="inactive-pagination">Next</span>
</button>
)}
</p>
</div>
);
};
PaginationFooter.propTypes = {
endpoint: PropTypes.string,
page: PropTypes.number,
limit: PropTypes.number,
numOffset: PropTypes.number,
numElements: PropTypes.number,
};
export default PaginationFooter;

View File

@@ -0,0 +1,14 @@
@import url(../../style/root.css);
.pagination-footer * button {
margin-right: 10px;
}
.pagination-footer * .inactive-pagination {
color: gray;
cursor: not-allowed;
}
.pagination-footer * button.spaced {
color: var(--blue);
}

View File

@@ -10,6 +10,7 @@ import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
import "./server-dashboard.css";
import { timeSince } from "../../util/timeSince";
import { jhapiRequest } from "../../util/jhapiUtil";
import PaginationFooter from "../PaginationFooter/PaginationFooter";
const ServerDashboard = (props) => {
// sort methods
@@ -30,10 +31,13 @@ const ServerDashboard = (props) => {
var [sortMethod, setSortMethod] = useState(null);
var user_data = useSelector((state) => state.user_data);
var user_page = useSelector((state) => state.user_page);
var limit = useSelector((state) => state.limit);
var page = parseInt(new URLSearchParams(props.location.search).get("page"));
var user_data = useSelector((state) => state.user_data),
user_page = useSelector((state) => state.user_page),
limit = useSelector((state) => state.limit),
page = parseInt(new URLSearchParams(props.location.search).get("page"));
console.log(user_page);
page = isNaN(page) ? 0 : page;
var slice = [page * limit, limit];
@@ -49,14 +53,7 @@ const ServerDashboard = (props) => {
history,
} = props;
var dispatchUserUpdate = (data) => {
dispatch({
type: "USER_DATA",
value: data,
});
};
var dispatchPageChange = (data, page) => {
var dispatchPageUpdate = (data, page) => {
dispatch({
type: "USER_PAGE",
value: {
@@ -71,9 +68,7 @@ const ServerDashboard = (props) => {
}
if (page != user_page) {
updateUsers(...slice)
.then((data) => data.json())
.then((data) => dispatchPageChange(data, page));
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
}
if (sortMethod != null) {
@@ -138,9 +133,8 @@ const ServerDashboard = (props) => {
Promise.all(startAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => data.json())
.then((data) => {
dispatchUserUpdate(data);
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
return res;
@@ -159,9 +153,8 @@ const ServerDashboard = (props) => {
Promise.all(stopAll(user_data.map((e) => e.name)))
.then((res) => {
updateUsers(...slice)
.then((data) => data.json())
.then((data) => {
dispatchUserUpdate(data);
dispatchPageUpdate(data, page);
})
.catch((err) => console.log(err));
return res;
@@ -198,10 +191,8 @@ const ServerDashboard = (props) => {
onClick={() =>
stopServer(e.name)
.then((res) => {
updateUsers(...slice)
.then((data) => data.json())
.then((data) => {
dispatchUserUpdate(data);
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
@@ -217,10 +208,8 @@ const ServerDashboard = (props) => {
onClick={() =>
startServer(e.name)
.then((res) => {
updateUsers(...slice)
.then((data) => data.json())
.then((data) => {
dispatchUserUpdate(data);
updateUsers(...slice).then((data) => {
dispatchPageUpdate(data, page);
});
return res;
})
@@ -253,24 +242,13 @@ const ServerDashboard = (props) => {
))}
</tbody>
</table>
<br></br>
<p>
Displaying users {slice[0]}-{slice[0] + user_data.length}
{user_data.length >= limit ? (
<button className="btn btn-link">
<Link to={`/?page=${page + 1}`}>Next</Link>
</button>
) : (
<></>
)}
{page >= 1 ? (
<button className="btn btn-link">
<Link to={`/?page=${page - 1}`}>Previous</Link>
</button>
) : (
<></>
)}
</p>
<PaginationFooter
endpoint="/"
page={page}
limit={limit}
numOffset={slice[0]}
numElements={user_data.length}
/>
<br></br>
</div>
</div>

View File

@@ -3,7 +3,7 @@
--orange: #f1ad4e;
--blue: #2e7ab6;
--white: #ffffff;
--gray: #f7f7f;
--gray: #f7f7f7;
}
/* Color Classes */

View File

@@ -3,7 +3,14 @@ import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps((props) => ({
updateUsers: (offset, limit) =>
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET"),
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
data.json()
),
updateGroups: (offset, limit) =>
jhapiRequest(
`/groups?offset=${offset}&limit=${limit}`,
"GET"
).then((data) => data.json()),
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),

File diff suppressed because one or more lines are too long