mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 10:34:10 +00:00
admin: persist page view info in url parameters
- persist offset, limit, name_filter in URL parameters, so they are stable across page reload - add UI element to specify items per page This allows specifying a URL, which will show a specific view of a page of users
This commit is contained in:
40
jsx/package-lock.json
generated
40
jsx/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"react-multi-select-component": "^4.3.4",
|
||||
"react-redux": "^7.2.8",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-router-dom-v5-compat": "^6.22.2",
|
||||
"recompose": "npm:react-recompose@^0.33.0",
|
||||
"redux": "^4.2.1",
|
||||
"regenerator-runtime": "^0.13.11"
|
||||
@@ -2408,6 +2409,14 @@
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.2.tgz",
|
||||
"integrity": "sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@restart/hooks": {
|
||||
"version": "0.4.9",
|
||||
"license": "MIT",
|
||||
@@ -8410,6 +8419,37 @@
|
||||
"react": ">=15"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom-v5-compat": {
|
||||
"version": "6.22.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom-v5-compat/-/react-router-dom-v5-compat-6.22.2.tgz",
|
||||
"integrity": "sha512-d7Bo6q3lRwUqtrmuULkpu3NECk+nOT3eNz6PnR5lGIWi0NN7gu6i281cqe3Cfu8v0MmNE41D0g/JdKJhfE7Brw==",
|
||||
"dependencies": {
|
||||
"history": "^5.3.0",
|
||||
"react-router": "6.22.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8",
|
||||
"react-router-dom": "4 || 5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom-v5-compat/node_modules/react-router": {
|
||||
"version": "6.22.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.2.tgz",
|
||||
"integrity": "sha512-YD3Dzprzpcq+tBMHBS822tCjnWD3iIZbTeSXMY9LPSG541EfoBGyZ3bS25KEnaZjLcmQpw2AVLkFyfgXY8uvcw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.15.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom/node_modules/history": {
|
||||
"version": "4.10.1",
|
||||
"license": "MIT",
|
||||
|
@@ -40,6 +40,7 @@
|
||||
"react-multi-select-component": "^4.3.4",
|
||||
"react-redux": "^7.2.8",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-router-dom-v5-compat": "^6.22.2",
|
||||
"recompose": "npm:react-recompose@^0.33.0",
|
||||
"redux": "^4.2.1",
|
||||
"regenerator-runtime": "^0.13.11"
|
||||
|
@@ -13,6 +13,7 @@ import GroupEdit from "./components/GroupEdit/GroupEdit";
|
||||
import CreateGroup from "./components/CreateGroup/CreateGroup";
|
||||
import AddUser from "./components/AddUser/AddUser";
|
||||
import EditUser from "./components/EditUser/EditUser";
|
||||
import { CompatRouter } from "react-router-dom-v5-compat";
|
||||
|
||||
import "./style/root.css";
|
||||
|
||||
@@ -23,13 +24,18 @@ const App = () => {
|
||||
<div className="resets">
|
||||
<Provider store={store}>
|
||||
<HashRouter>
|
||||
<CompatRouter>
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/"
|
||||
component={compose(withAPI)(ServerDashboard)}
|
||||
/>
|
||||
<Route exact path="/groups" component={compose(withAPI)(Groups)} />
|
||||
<Route
|
||||
exact
|
||||
path="/groups"
|
||||
component={compose(withAPI)(Groups)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/group-edit"
|
||||
@@ -51,6 +57,7 @@ const App = () => {
|
||||
component={compose(withAPI)(EditUser)}
|
||||
/>
|
||||
</Switch>
|
||||
</CompatRouter>
|
||||
</HashRouter>
|
||||
</Provider>
|
||||
</div>
|
||||
|
@@ -17,6 +17,13 @@ export const reducers = (state = initialState, action) => {
|
||||
}),
|
||||
});
|
||||
|
||||
case "USER_LIMIT":
|
||||
return Object.assign({}, state, {
|
||||
user_page: Object.assign({}, state.user_page, {
|
||||
limit: action.value.limit,
|
||||
}),
|
||||
});
|
||||
|
||||
case "USER_NAME_FILTER":
|
||||
// set offset to 0 if name filter changed,
|
||||
// otherwise leave it alone
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSearchParams } from "react-router-dom-v5-compat";
|
||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||
|
||||
const Groups = (props) => {
|
||||
@@ -10,9 +12,20 @@ const Groups = (props) => {
|
||||
groups_page = useSelector((state) => state.groups_page),
|
||||
dispatch = useDispatch();
|
||||
|
||||
var offset = groups_page ? groups_page.offset : 0;
|
||||
let [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
var offset = parseInt(searchParams.get("offset", "0")) || 0;
|
||||
var limit = parseInt(searchParams.get("limit", "0")) || window.api_page_limit;
|
||||
|
||||
const setOffset = (offset) => {
|
||||
console.log("setting offset", offset);
|
||||
if (offset < 0) {
|
||||
offset = 0;
|
||||
}
|
||||
setSearchParams((params) => {
|
||||
params.set("offset", offset);
|
||||
return params;
|
||||
});
|
||||
dispatch({
|
||||
type: "GROUPS_OFFSET",
|
||||
value: {
|
||||
@@ -20,7 +33,23 @@ const Groups = (props) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
var limit = groups_page ? groups_page.limit : window.api_page_limit;
|
||||
|
||||
const setLimit = (newLimit) => {
|
||||
if (newLimit < 1) {
|
||||
newLimit = 10;
|
||||
}
|
||||
setSearchParams((params) => {
|
||||
params.set("limit", newLimit);
|
||||
return params;
|
||||
});
|
||||
dispatch({
|
||||
type: "GROUP_LIMIT",
|
||||
value: {
|
||||
limit: newLimit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
var total = groups_page ? groups_page.total : undefined;
|
||||
|
||||
var { updateGroups, history } = props;
|
||||
@@ -45,6 +74,10 @@ const Groups = (props) => {
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
const handleLimit = debounce(async (event) => {
|
||||
setLimit(event.target.value);
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<div className="container" data-testid="container">
|
||||
<div className="row">
|
||||
@@ -85,7 +118,8 @@ const Groups = (props) => {
|
||||
visible={groups_data.length}
|
||||
total={total}
|
||||
next={() => setOffset(offset + limit)}
|
||||
prev={() => setOffset(offset >= limit ? offset - limit : 0)}
|
||||
prev={() => setOffset(offset - limit)}
|
||||
handleLimit={handleLimit}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel-footer">
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { FormControl } from "react-bootstrap";
|
||||
|
||||
import "./pagination-footer.css";
|
||||
|
||||
const PaginationFooter = (props) => {
|
||||
let { offset, limit, visible, total, next, prev } = props;
|
||||
let { offset, limit, visible, total, next, prev, handleLimit } = props;
|
||||
return (
|
||||
<div className="pagination-footer">
|
||||
<p>
|
||||
Displaying {offset}-{offset + visible}
|
||||
<br></br>
|
||||
<br></br>
|
||||
<br />
|
||||
{offset >= 1 ? (
|
||||
<button className="btn btn-sm btn-light spaced">
|
||||
<span
|
||||
@@ -41,6 +41,19 @@ const PaginationFooter = (props) => {
|
||||
<span className="inactive-pagination">Next</span>
|
||||
</button>
|
||||
)}
|
||||
<label>
|
||||
Items per page:
|
||||
<FormControl
|
||||
type="number"
|
||||
min="25"
|
||||
step="25"
|
||||
name="pagination-limit"
|
||||
placeholder={limit}
|
||||
aria-label="pagination-limit"
|
||||
defaultValue={limit}
|
||||
onChange={handleLimit}
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
@@ -15,6 +15,9 @@ import {
|
||||
import ReactObjectTableViewer from "../ReactObjectTableViewer/ReactObjectTableViewer";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
// react-router-dom v6 API
|
||||
// should be able to upgrade to v6 someday
|
||||
import { useSearchParams } from "react-router-dom-v5-compat";
|
||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||
|
||||
import "./server-dashboard.css";
|
||||
@@ -30,6 +33,7 @@ RowListItem.propTypes = {
|
||||
|
||||
const ServerDashboard = (props) => {
|
||||
let base_url = window.base_url || "/";
|
||||
let [searchParams, setSearchParams] = useSearchParams();
|
||||
// sort methods
|
||||
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
||||
@@ -54,8 +58,11 @@ const ServerDashboard = (props) => {
|
||||
user_page = useSelector((state) => state.user_page),
|
||||
name_filter = useSelector((state) => state.name_filter);
|
||||
|
||||
var offset = user_page ? user_page.offset : 0;
|
||||
var limit = user_page ? user_page.limit : window.api_page_limit;
|
||||
// get offset, limit, name filter from URL
|
||||
var offset = parseInt(searchParams.get("offset", "0")) || 0;
|
||||
var limit = parseInt(searchParams.get("limit", "0")) || window.api_page_limit;
|
||||
var searchNameFilter = searchParams.get("name_filter");
|
||||
|
||||
var total = user_page ? user_page.total : undefined;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
@@ -82,6 +89,13 @@ const ServerDashboard = (props) => {
|
||||
};
|
||||
|
||||
const setOffset = (newOffset) => {
|
||||
if (newOffset < 0) {
|
||||
newOffset = 0;
|
||||
}
|
||||
setSearchParams((params) => {
|
||||
params.set("offset", newOffset);
|
||||
return params;
|
||||
});
|
||||
dispatch({
|
||||
type: "USER_OFFSET",
|
||||
value: {
|
||||
@@ -90,7 +104,27 @@ const ServerDashboard = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const setLimit = (newLimit) => {
|
||||
if (newLimit < 1) {
|
||||
newLimit = 10;
|
||||
}
|
||||
setSearchParams((params) => {
|
||||
params.set("limit", newLimit);
|
||||
return params;
|
||||
});
|
||||
dispatch({
|
||||
type: "USER_LIMIT",
|
||||
value: {
|
||||
limit: newLimit,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setNameFilter = (name_filter) => {
|
||||
setSearchParams((params) => {
|
||||
params.set("name_filter", name_filter);
|
||||
return params;
|
||||
});
|
||||
dispatch({
|
||||
type: "USER_NAME_FILTER",
|
||||
value: {
|
||||
@@ -105,6 +139,11 @@ const ServerDashboard = (props) => {
|
||||
.catch((err) => setErrorAlert("Failed to update user list."));
|
||||
}, [offset, limit, name_filter]);
|
||||
|
||||
if (searchNameFilter && name_filter != searchNameFilter) {
|
||||
// get name_filter from URL
|
||||
setNameFilter(searchNameFilter);
|
||||
}
|
||||
|
||||
if (!user_data || !user_page) {
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
@@ -115,6 +154,10 @@ const ServerDashboard = (props) => {
|
||||
setNameFilter(event.target.value);
|
||||
}, 300);
|
||||
|
||||
const handleLimit = debounce(async (event) => {
|
||||
setLimit(event.target.value);
|
||||
}, 300);
|
||||
|
||||
if (sortMethod != null) {
|
||||
user_data = sortMethod(user_data);
|
||||
}
|
||||
@@ -573,6 +616,7 @@ const ServerDashboard = (props) => {
|
||||
total={total}
|
||||
next={() => setOffset(offset + limit)}
|
||||
prev={() => setOffset(offset - limit)}
|
||||
handleLimit={handleLimit}
|
||||
/>
|
||||
<br></br>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user