mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-16 22:43:00 +00:00
Merge pull request #4722 from minrk/sort-order-admin-ui
server-side sorting of admin page
This commit is contained in:
13
jsx/package-lock.json
generated
13
jsx/package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^17.0.2",
|
||||
"react-bootstrap": "^2.7.4",
|
||||
"react-bootstrap": "^2.10.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-multi-select-component": "^4.3.4",
|
||||
@@ -8209,13 +8209,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-bootstrap": {
|
||||
"version": "2.7.4",
|
||||
"license": "MIT",
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.1.tgz",
|
||||
"integrity": "sha512-J3OpRZIvCTQK+Tg/jOkRUvpYLHMdGeU9KqFUBQrV0d/Qr/3nsINpiOJyZMWnM5SJ3ctZdhPA6eCIKpEJR3Ellg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"@babel/runtime": "^7.22.5",
|
||||
"@restart/hooks": "^0.4.9",
|
||||
"@restart/ui": "^1.6.3",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"@restart/ui": "^1.6.6",
|
||||
"@types/react-transition-group": "^4.4.6",
|
||||
"classnames": "^2.3.2",
|
||||
"dom-helpers": "^5.2.1",
|
||||
"invariant": "^2.2.4",
|
||||
|
@@ -34,7 +34,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^17.0.2",
|
||||
"react-bootstrap": "^2.7.4",
|
||||
"react-bootstrap": "^2.10.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-multi-select-component": "^4.3.4",
|
||||
|
@@ -5,11 +5,12 @@ import { FormControl } from "react-bootstrap";
|
||||
import "./pagination-footer.css";
|
||||
|
||||
const PaginationFooter = (props) => {
|
||||
let { offset, limit, visible, total, next, prev, handleLimit } = props;
|
||||
const { offset, limit, visible, total, next, prev, handleLimit } = props;
|
||||
return (
|
||||
<div className="pagination-footer">
|
||||
<p>
|
||||
Displaying {offset + 1}-{offset + visible} {total ? `of ${total}` : ""}
|
||||
Displaying {visible ? offset + 1 : offset}-{offset + visible}{" "}
|
||||
{total ? `of ${total}` : ""}
|
||||
<br />
|
||||
{offset >= 1 ? (
|
||||
<button className="btn btn-sm btn-light spaced">
|
||||
|
@@ -7,6 +7,7 @@ import {
|
||||
Button,
|
||||
Col,
|
||||
Row,
|
||||
Form,
|
||||
FormControl,
|
||||
Card,
|
||||
CardGroup,
|
||||
@@ -32,24 +33,8 @@ RowListItem.propTypes = {
|
||||
const ServerDashboard = (props) => {
|
||||
const base_url = window.base_url || "/";
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// sort methods
|
||||
const usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
||||
adminDesc = (e) => e.sort((a) => (a.admin ? -1 : 1)),
|
||||
adminAsc = (e) => e.sort((a) => (a.admin ? 1 : -1)),
|
||||
dateDesc = (e) =>
|
||||
e.sort((a, b) =>
|
||||
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? -1 : 1,
|
||||
),
|
||||
dateAsc = (e) =>
|
||||
e.sort((a, b) =>
|
||||
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? 1 : -1,
|
||||
),
|
||||
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
|
||||
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
|
||||
|
||||
const [errorAlert, setErrorAlert] = useState(null);
|
||||
const [sortMethod, setSortMethod] = useState(null);
|
||||
const [collapseStates, setCollapseStates] = useState({});
|
||||
|
||||
let user_data = useSelector((state) => state.user_data);
|
||||
@@ -59,6 +44,8 @@ const ServerDashboard = (props) => {
|
||||
usePaginationParams();
|
||||
|
||||
const name_filter = searchParams.get("name_filter") || "";
|
||||
const sort = searchParams.get("sort") || "id";
|
||||
const state_filter = searchParams.get("state") || "";
|
||||
|
||||
const total = user_page ? user_page.total : undefined;
|
||||
|
||||
@@ -76,7 +63,13 @@ const ServerDashboard = (props) => {
|
||||
} = props;
|
||||
|
||||
const dispatchPageUpdate = (data, page) => {
|
||||
// trigger page update in state
|
||||
// in response to fetching updated user list
|
||||
// data is list of user records
|
||||
// page is _pagination part of response
|
||||
// persist page info in url query
|
||||
setPagination(page);
|
||||
// persist user data, triggers rerender
|
||||
dispatch({
|
||||
type: "USER_PAGE",
|
||||
value: {
|
||||
@@ -87,6 +80,8 @@ const ServerDashboard = (props) => {
|
||||
};
|
||||
|
||||
const setNameFilter = (new_name_filter) => {
|
||||
// persist ?name_filter parameter
|
||||
// store in url param, clear when value is empty
|
||||
setSearchParams((params) => {
|
||||
// clear offset when name filter changes
|
||||
if (new_name_filter !== name_filter) {
|
||||
@@ -101,26 +96,61 @@ const ServerDashboard = (props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const setSort = (sort) => {
|
||||
// persist ?sort parameter
|
||||
// store in url param, clear when value is default ('id')
|
||||
setSearchParams((params) => {
|
||||
if (sort === "id") {
|
||||
params.delete("id");
|
||||
} else {
|
||||
params.set("sort", sort);
|
||||
}
|
||||
return params;
|
||||
});
|
||||
};
|
||||
|
||||
const setStateFilter = (new_state_filter) => {
|
||||
// persist ?state filter
|
||||
// store in url param, clear when value is default ('')
|
||||
setSearchParams((params) => {
|
||||
// clear offset when filter changes
|
||||
if (new_state_filter !== state_filter) {
|
||||
params.delete("offset");
|
||||
}
|
||||
if (!new_state_filter) {
|
||||
params.delete("state");
|
||||
} else {
|
||||
params.set("state", new_state_filter);
|
||||
}
|
||||
console.log("setting search params", params.toString());
|
||||
return params;
|
||||
});
|
||||
};
|
||||
|
||||
// the callback to update the displayed user list
|
||||
const updateUsersWithParams = () =>
|
||||
updateUsers({
|
||||
offset,
|
||||
limit,
|
||||
name_filter,
|
||||
sort,
|
||||
state: state_filter,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateUsers(offset, limit, name_filter)
|
||||
updateUsersWithParams()
|
||||
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
||||
.catch((err) => setErrorAlert("Failed to update user list."));
|
||||
}, [offset, limit, name_filter]);
|
||||
}, [offset, limit, name_filter, sort, state_filter]);
|
||||
|
||||
if (!user_data || !user_page) {
|
||||
return <div data-testid="no-show"></div>;
|
||||
}
|
||||
|
||||
var slice = [offset, limit, name_filter];
|
||||
|
||||
const handleSearch = debounce(async (event) => {
|
||||
setNameFilter(event.target.value);
|
||||
}, 300);
|
||||
|
||||
if (sortMethod != null) {
|
||||
user_data = sortMethod(user_data);
|
||||
}
|
||||
|
||||
const ServerButton = ({ server, user, action, name, extraClass }) => {
|
||||
var [isDisabled, setIsDisabled] = useState(false);
|
||||
return (
|
||||
@@ -132,7 +162,7 @@ const ServerDashboard = (props) => {
|
||||
action(user.name, server.name)
|
||||
.then((res) => {
|
||||
if (res.status < 300) {
|
||||
updateUsers(...slice)
|
||||
updateUsersWithParams()
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data.items, data._pagination);
|
||||
})
|
||||
@@ -170,7 +200,7 @@ const ServerDashboard = (props) => {
|
||||
});
|
||||
};
|
||||
const DeleteServerButton = ({ server, user }) => {
|
||||
if (server.name === "") {
|
||||
if (!server.name) {
|
||||
// It's not possible to delete unnamed servers
|
||||
return null;
|
||||
}
|
||||
@@ -225,25 +255,22 @@ const ServerDashboard = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const EditUserCell = ({ user }) => {
|
||||
const EditUserButton = ({ user }) => {
|
||||
return (
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-primary btn-xs"
|
||||
style={{ marginRight: 20 }}
|
||||
onClick={() =>
|
||||
history.push({
|
||||
pathname: "/edit-user",
|
||||
state: {
|
||||
username: user.name,
|
||||
has_admin: user.admin,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit User
|
||||
</button>
|
||||
</td>
|
||||
<button
|
||||
className="btn btn-light btn-xs"
|
||||
onClick={() =>
|
||||
history.push({
|
||||
pathname: "/edit-user",
|
||||
state: {
|
||||
username: user.name,
|
||||
has_admin: user.admin,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit User
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -337,8 +364,8 @@ const ServerDashboard = (props) => {
|
||||
<DeleteServerButton server={server} user={user} />
|
||||
<AccessServerButton server={server} />
|
||||
<SpawnPageButton server={server} user={user} />
|
||||
<EditUserButton user={user} />
|
||||
</td>
|
||||
<EditUserCell user={user} />
|
||||
</tr>,
|
||||
<tr key={`${userServerName}-detail`}>
|
||||
<td
|
||||
@@ -406,6 +433,24 @@ const ServerDashboard = (props) => {
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
{/* div.checkbox required for BS3 CSS */}
|
||||
<div class="checkbox">
|
||||
<label title="check to only show running servers, otherwise show all">
|
||||
<Form.Check
|
||||
inline
|
||||
type="checkbox"
|
||||
name="active_servers"
|
||||
id="active-servers-filter"
|
||||
checked={state_filter == "active"}
|
||||
onChange={(event) => {
|
||||
setStateFilter(event.target.checked ? "active" : null);
|
||||
}}
|
||||
/>
|
||||
{"only active servers"}
|
||||
</label>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col md="auto" style={{ float: "right", margin: 15 }}>
|
||||
<Link to="/groups">{"> Manage Groups"}</Link>
|
||||
@@ -417,43 +462,23 @@ const ServerDashboard = (props) => {
|
||||
<th id="user-header">
|
||||
User{" "}
|
||||
<SortHandler
|
||||
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
currentSort={sort}
|
||||
setSort={setSort}
|
||||
sortKey="name"
|
||||
testid="user-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="admin-header">
|
||||
Admin{" "}
|
||||
<SortHandler
|
||||
sorts={{ asc: adminAsc, desc: adminDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="admin-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="server-header">
|
||||
Server{" "}
|
||||
<SortHandler
|
||||
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="server-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="admin-header">Admin</th>
|
||||
<th id="server-header">Server</th>
|
||||
<th id="last-activity-header">
|
||||
Last Activity{" "}
|
||||
<SortHandler
|
||||
sorts={{ asc: dateAsc, desc: dateDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
currentSort={sort}
|
||||
setSort={setSort}
|
||||
sortKey="last_activity"
|
||||
testid="last-activity-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="running-status-header">
|
||||
Running{" "}
|
||||
<SortHandler
|
||||
sorts={{ asc: runningAsc, desc: runningDesc }}
|
||||
callback={(method) => setSortMethod(() => method)}
|
||||
testid="running-status-sort"
|
||||
/>
|
||||
</th>
|
||||
<th id="actions-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -466,14 +491,13 @@ const ServerDashboard = (props) => {
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<td colSpan={4} className="admin-header-buttons">
|
||||
{/* Start all servers */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="start-all"
|
||||
data-testid="start-all"
|
||||
title="start all servers on the current page"
|
||||
onClick={() => {
|
||||
Promise.all(startAll(user_data.map((e) => e.name)))
|
||||
.then((res) => {
|
||||
@@ -490,7 +514,7 @@ const ServerDashboard = (props) => {
|
||||
return res;
|
||||
})
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
updateUsersWithParams()
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data.items, data._pagination);
|
||||
})
|
||||
@@ -510,6 +534,7 @@ const ServerDashboard = (props) => {
|
||||
variant="danger"
|
||||
className="stop-all"
|
||||
data-testid="stop-all"
|
||||
title="stop all servers on the current page"
|
||||
onClick={() => {
|
||||
Promise.all(stopAll(user_data.map((e) => e.name)))
|
||||
.then((res) => {
|
||||
@@ -526,7 +551,7 @@ const ServerDashboard = (props) => {
|
||||
return res;
|
||||
})
|
||||
.then((res) => {
|
||||
updateUsers(...slice)
|
||||
updateUsersWithParams()
|
||||
.then((data) => {
|
||||
dispatchPageUpdate(data.items, data._pagination);
|
||||
})
|
||||
@@ -540,8 +565,8 @@ const ServerDashboard = (props) => {
|
||||
>
|
||||
Stop All
|
||||
</Button>
|
||||
</td>
|
||||
<td>
|
||||
{/* spacing between start/stop and Shutdown */}
|
||||
<span style={{ marginLeft: "56px" }}> </span>
|
||||
{/* Shutdown Jupyterhub */}
|
||||
<Button
|
||||
variant="danger"
|
||||
@@ -551,7 +576,6 @@ const ServerDashboard = (props) => {
|
||||
Shutdown Hub
|
||||
</Button>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
||||
</tbody>
|
||||
@@ -590,30 +614,27 @@ ServerDashboard.propTypes = {
|
||||
};
|
||||
|
||||
const SortHandler = (props) => {
|
||||
var { sorts, callback, testid } = props;
|
||||
|
||||
var [direction, setDirection] = useState(undefined);
|
||||
const { currentSort, setSort, sortKey, testid } = props;
|
||||
|
||||
const currentlySorted = currentSort && currentSort.endsWith(sortKey);
|
||||
const descending = currentSort && currentSort.startsWith("-");
|
||||
return (
|
||||
<div
|
||||
className="sort-icon"
|
||||
data-testid={testid}
|
||||
onClick={() => {
|
||||
if (!direction) {
|
||||
callback(sorts.desc);
|
||||
setDirection("desc");
|
||||
} else if (direction == "asc") {
|
||||
callback(sorts.desc);
|
||||
setDirection("desc");
|
||||
if (!currentlySorted) {
|
||||
setSort(sortKey);
|
||||
} else if (descending) {
|
||||
setSort(sortKey);
|
||||
} else {
|
||||
callback(sorts.asc);
|
||||
setDirection("asc");
|
||||
setSort("-" + sortKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!direction ? (
|
||||
{!currentlySorted ? (
|
||||
<FaSort />
|
||||
) : direction == "asc" ? (
|
||||
) : descending ? (
|
||||
<FaSortDown />
|
||||
) : (
|
||||
<FaSortUp />
|
||||
@@ -623,8 +644,9 @@ const SortHandler = (props) => {
|
||||
};
|
||||
|
||||
SortHandler.propTypes = {
|
||||
sorts: PropTypes.object,
|
||||
callback: PropTypes.func,
|
||||
currentSort: PropTypes.string,
|
||||
setSort: PropTypes.func,
|
||||
sortKey: PropTypes.string,
|
||||
testid: PropTypes.string,
|
||||
};
|
||||
|
||||
|
@@ -34,9 +34,8 @@ const serverDashboardJsx = (props) => {
|
||||
// spies is a dict of properties to mock in
|
||||
// any API calls that will fire during the test should be mocked
|
||||
props = props || {};
|
||||
const defaultSpy = mockAsync();
|
||||
if (!props.updateUsers) {
|
||||
props.updateUsers = defaultSpy;
|
||||
props.updateUsers = mockUpdateUsers;
|
||||
}
|
||||
return (
|
||||
<Provider store={createStore(mockReducers, mockAppState())}>
|
||||
@@ -55,6 +54,14 @@ var mockAsync = (data) =>
|
||||
var mockAsyncRejection = () =>
|
||||
jest.fn().mockImplementation(() => Promise.reject());
|
||||
|
||||
const defaultUpdateUsersParams = {
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
name_filter: "",
|
||||
sort: "id",
|
||||
state: "",
|
||||
};
|
||||
|
||||
var bar_servers = {
|
||||
"": {
|
||||
name: "",
|
||||
@@ -80,44 +87,64 @@ var bar_servers = {
|
||||
},
|
||||
};
|
||||
|
||||
/* create new user models */
|
||||
const newUser = (name) => {
|
||||
return {
|
||||
kind: "user",
|
||||
name: name,
|
||||
admin: false,
|
||||
groups: [],
|
||||
server: `/user/${name}`,
|
||||
created: "2020-12-07T18:46:27.112695Z",
|
||||
last_activity: "2020-12-07T21:00:33.336354Z",
|
||||
servers: {},
|
||||
};
|
||||
};
|
||||
|
||||
const allUsers = [
|
||||
{
|
||||
kind: "user",
|
||||
name: "foo",
|
||||
admin: true,
|
||||
groups: [],
|
||||
server: "/user/foo/",
|
||||
pending: null,
|
||||
created: "2020-12-07T18:46:27.112695Z",
|
||||
last_activity: "2020-12-07T21:00:33.336354Z",
|
||||
servers: {
|
||||
"": {
|
||||
name: "",
|
||||
last_activity: "2020-12-07T20:58:02.437408Z",
|
||||
started: "2020-12-07T20:58:01.508266Z",
|
||||
pending: null,
|
||||
ready: true,
|
||||
state: { pid: 28085 },
|
||||
url: "/user/foo/",
|
||||
user_options: {},
|
||||
progress_url: "/hub/api/users/foo/server/progress",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "user",
|
||||
name: "bar",
|
||||
admin: false,
|
||||
groups: [],
|
||||
server: null,
|
||||
pending: null,
|
||||
created: "2020-12-07T18:46:27.115528Z",
|
||||
last_activity: "2020-12-07T20:43:51.013613Z",
|
||||
servers: bar_servers,
|
||||
},
|
||||
];
|
||||
|
||||
for (var i = 2; i < 10; i++) {
|
||||
allUsers.push(newUser(`test-${i}`));
|
||||
}
|
||||
|
||||
var mockAppState = () =>
|
||||
Object.assign({}, initialState, {
|
||||
user_data: [
|
||||
{
|
||||
kind: "user",
|
||||
name: "foo",
|
||||
admin: true,
|
||||
groups: [],
|
||||
server: "/user/foo/",
|
||||
pending: null,
|
||||
created: "2020-12-07T18:46:27.112695Z",
|
||||
last_activity: "2020-12-07T21:00:33.336354Z",
|
||||
servers: {
|
||||
"": {
|
||||
name: "",
|
||||
last_activity: "2020-12-07T20:58:02.437408Z",
|
||||
started: "2020-12-07T20:58:01.508266Z",
|
||||
pending: null,
|
||||
ready: true,
|
||||
state: { pid: 28085 },
|
||||
url: "/user/foo/",
|
||||
user_options: {},
|
||||
progress_url: "/hub/api/users/foo/server/progress",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "user",
|
||||
name: "bar",
|
||||
admin: false,
|
||||
groups: [],
|
||||
server: null,
|
||||
pending: null,
|
||||
created: "2020-12-07T18:46:27.115528Z",
|
||||
last_activity: "2020-12-07T20:43:51.013613Z",
|
||||
servers: bar_servers,
|
||||
},
|
||||
],
|
||||
user_data: allUsers.slice(0, 2),
|
||||
user_page: {
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
@@ -125,7 +152,7 @@ var mockAppState = () =>
|
||||
next: {
|
||||
offset: 2,
|
||||
limit: 2,
|
||||
url: "http://localhost:8000/hub/api/groups?offset=2&limit=2",
|
||||
url: "http://localhost:8000/hub/api/users?offset=2&limit=2",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -143,6 +170,40 @@ var mockReducers = jest.fn((state, action) => {
|
||||
return state;
|
||||
});
|
||||
|
||||
let mockUpdateUsers = jest.fn(({ offset, limit, sort, name_filter, state }) => {
|
||||
/* mock updating users
|
||||
|
||||
this has tom implement the server-side filtering, sorting, etc.
|
||||
(at least whatever we want to test of it)
|
||||
*/
|
||||
let matchingUsers = allUsers;
|
||||
if (state === "active") {
|
||||
// only first user is active
|
||||
matchingUsers = allUsers.slice(0, 1);
|
||||
}
|
||||
if (name_filter) {
|
||||
matchingUsers = matchingUsers.filter((user) =>
|
||||
user.name.startsWith(name_filter),
|
||||
);
|
||||
}
|
||||
|
||||
const total = matchingUsers.length;
|
||||
const items = matchingUsers.slice(offset, offset + limit);
|
||||
|
||||
return Promise.resolve({
|
||||
items: items,
|
||||
_pagination: {
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
total: total,
|
||||
next: {
|
||||
offset: offset + limit,
|
||||
limit: limit,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
let searchParams = new URLSearchParams();
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -151,6 +212,7 @@ beforeEach(() => {
|
||||
return callback(mockAppState());
|
||||
});
|
||||
searchParams = new URLSearchParams();
|
||||
searchParams.set("limit", "2");
|
||||
|
||||
useSearchParams.mockImplementation(() => [
|
||||
searchParams,
|
||||
@@ -164,6 +226,7 @@ afterEach(() => {
|
||||
useSearchParams.mockClear();
|
||||
useSelector.mockClear();
|
||||
mockReducers.mockClear();
|
||||
mockUpdateUsers.mockClear();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
@@ -267,71 +330,93 @@ test("Invokes the shutdownHub event on button click", async () => {
|
||||
});
|
||||
|
||||
test("Sorts according to username", async () => {
|
||||
let rerender;
|
||||
const testId = "user-sort";
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx());
|
||||
rerender = render(serverDashboardJsx()).rerender;
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("user-sort");
|
||||
expect(searchParams.get("sort")).toEqual(null);
|
||||
let handler = screen.getByTestId(testId);
|
||||
fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("name");
|
||||
|
||||
let first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("bar");
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("foo");
|
||||
});
|
||||
|
||||
test("Sorts according to admin", async () => {
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx());
|
||||
rerender(serverDashboardJsx());
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("admin-sort");
|
||||
fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("-name");
|
||||
|
||||
let first = screen.getAllByTestId("user-row-admin")[0];
|
||||
expect(first.textContent).toBe("admin");
|
||||
await act(async () => {
|
||||
rerender(serverDashboardJsx());
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-admin")[0];
|
||||
expect(first.textContent).toBe("");
|
||||
expect(searchParams.get("sort")).toEqual("name");
|
||||
});
|
||||
|
||||
test("Sorts according to last activity", async () => {
|
||||
let rerender;
|
||||
const testId = "last-activity-sort";
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx());
|
||||
rerender = render(serverDashboardJsx()).rerender;
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("last-activity-sort");
|
||||
expect(searchParams.get("sort")).toEqual(null);
|
||||
let handler = screen.getByTestId(testId);
|
||||
fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("last_activity");
|
||||
|
||||
let first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("foo");
|
||||
await act(async () => {
|
||||
rerender(serverDashboardJsx());
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("-last_activity");
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("bar");
|
||||
await act(async () => {
|
||||
rerender(serverDashboardJsx());
|
||||
handler = screen.getByTestId(testId);
|
||||
});
|
||||
|
||||
fireEvent.click(handler);
|
||||
expect(searchParams.get("sort")).toEqual("last_activity");
|
||||
});
|
||||
|
||||
test("Sorts according to server status (running/not running)", async () => {
|
||||
test("Filter according to server status (running/not running)", async () => {
|
||||
let rerender;
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx());
|
||||
rerender = render(serverDashboardJsx()).rerender;
|
||||
});
|
||||
|
||||
let handler = screen.getByTestId("running-status-sort");
|
||||
console.log(rerender);
|
||||
console.log("begin test");
|
||||
const label = "only active servers";
|
||||
let handler = screen.getByLabelText(label);
|
||||
expect(handler.checked).toEqual(false);
|
||||
fireEvent.click(handler);
|
||||
|
||||
let first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("foo");
|
||||
// FIXME: need to force a rerender to get updated checkbox
|
||||
// I don't think this should be required
|
||||
await act(async () => {
|
||||
rerender(serverDashboardJsx());
|
||||
handler = screen.getByLabelText(label);
|
||||
});
|
||||
expect(searchParams.get("state")).toEqual("active");
|
||||
expect(handler.checked).toEqual(true);
|
||||
|
||||
fireEvent.click(handler);
|
||||
|
||||
first = screen.getAllByTestId("user-row-name")[0];
|
||||
expect(first.textContent).toContain("bar");
|
||||
await act(async () => {
|
||||
rerender(serverDashboardJsx());
|
||||
handler = screen.getByLabelText(label);
|
||||
});
|
||||
handler = screen.getByLabelText(label);
|
||||
expect(handler.checked).toEqual(false);
|
||||
expect(searchParams.get("state")).toEqual(null);
|
||||
});
|
||||
|
||||
test("Shows server details with button click", async () => {
|
||||
@@ -494,23 +579,9 @@ test("Shows a UI error dialogue when stop user server returns an improper status
|
||||
|
||||
test("Search for user calls updateUsers with name filter", async () => {
|
||||
let spy = mockAsync();
|
||||
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
|
||||
return Promise.resolve({
|
||||
items: [],
|
||||
_pagination: {
|
||||
offset: offset,
|
||||
limit: limit,
|
||||
total: offset + limit * 2,
|
||||
next: {
|
||||
offset: offset + limit,
|
||||
limit: limit,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
searchParams.set("offset", "2");
|
||||
render(serverDashboardJsx({ updateUsers: mockUpdateUsers }));
|
||||
render(serverDashboardJsx());
|
||||
});
|
||||
|
||||
let search = screen.getByLabelText("user-search");
|
||||
@@ -538,17 +609,15 @@ test("Search for user calls updateUsers with name filter", async () => {
|
||||
});
|
||||
|
||||
test("Interacting with PaginationFooter causes state update and refresh via useEffect call", async () => {
|
||||
let updateUsers = mockAsync();
|
||||
|
||||
await act(async () => {
|
||||
render(serverDashboardJsx({ updateUsers: updateUsers }));
|
||||
render(serverDashboardJsx());
|
||||
});
|
||||
|
||||
expect(updateUsers).toBeCalledWith(0, 100, "");
|
||||
expect(mockUpdateUsers).toBeCalledWith(defaultUpdateUsersParams);
|
||||
|
||||
var n = 3;
|
||||
expect(searchParams.get("offset")).toEqual(null);
|
||||
expect(searchParams.get("limit")).toEqual(null);
|
||||
expect(searchParams.get("limit")).toEqual("2");
|
||||
|
||||
let next = screen.getByTestId("paginate-next");
|
||||
await act(async () => {
|
||||
@@ -556,8 +625,8 @@ test("Interacting with PaginationFooter causes state update and refresh via useE
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(searchParams.get("offset")).toEqual("100");
|
||||
expect(searchParams.get("limit")).toEqual(null);
|
||||
expect(searchParams.get("offset")).toEqual("2");
|
||||
expect(searchParams.get("limit")).toEqual("2");
|
||||
|
||||
// FIXME: should call updateUsers, does in reality.
|
||||
// tests don't reflect reality due to mocked state/useSelector
|
||||
@@ -590,12 +659,9 @@ test("Start server and confirm pending state", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
let mockUpdateUsers = jest.fn(() => Promise.resolve(mockAppState()));
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
serverDashboardJsx({
|
||||
updateUsers: mockUpdateUsers,
|
||||
startServer: mockStartServer,
|
||||
}),
|
||||
);
|
||||
@@ -604,16 +670,17 @@ test("Start server and confirm pending state", async () => {
|
||||
let actions = screen.getAllByTestId("user-row-server-activity")[1];
|
||||
let buttons = getAllByRole(actions, "button");
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons.length).toBe(3);
|
||||
expect(buttons[0].textContent).toBe("Start Server");
|
||||
expect(buttons[1].textContent).toBe("Spawn Page");
|
||||
expect(buttons[2].textContent).toBe("Edit User");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(buttons[0]);
|
||||
});
|
||||
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons.length).toBe(3);
|
||||
expect(buttons[0].textContent).toBe("Start Server");
|
||||
expect(buttons[0]).toBeDisabled();
|
||||
expect(buttons[1].textContent).toBe("Spawn Page");
|
||||
|
@@ -7,6 +7,13 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
/* backport bs5 btn-light colors */
|
||||
background-color: #f9fafb;
|
||||
border-color: #f9fafb;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.server-dashboard-container .btn-light {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
@@ -43,6 +50,47 @@ tr.noborder > td {
|
||||
vertical-align: inherit;
|
||||
}
|
||||
|
||||
.user-row .actions > * {
|
||||
margin-right: 5px;
|
||||
.user-row .actions button {
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.admin-header-buttons {
|
||||
/* float header action buttons to the right */
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* column widths for dashboard
|
||||
|
||||
goals:
|
||||
|
||||
- want stable width for running-status
|
||||
so clicking the running filter doesn't cause a jump
|
||||
- shrink fixed-content columns (action, admin)
|
||||
- allow variable content columns (username, server name)
|
||||
to claim remaining space
|
||||
|
||||
*/
|
||||
|
||||
.admin-table-head #user-header {
|
||||
}
|
||||
.admin-table-head #admin-header {
|
||||
width: 64px;
|
||||
}
|
||||
.admin-table-head #last-activity-header {
|
||||
min-width: 180px;
|
||||
}
|
||||
.admin-table-head #actions-header {
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
/* vertical stack server buttons on small windows */
|
||||
@media (max-width: 991px) {
|
||||
.admin-table-head #actions-header {
|
||||
width: 140px;
|
||||
}
|
||||
.user-row .actions button {
|
||||
/* full-width buttons when they get collapsed into a single column */
|
||||
margin: 4px 0px 4px 0px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
@@ -3,14 +3,11 @@ const base_url = jhdata.base_url || "/";
|
||||
const xsrfToken = jhdata.xsrf_token;
|
||||
|
||||
export const jhapiRequest = (endpoint, method, data) => {
|
||||
let api_url = `${base_url}hub/api`;
|
||||
let suffix = "";
|
||||
let api_url = new URL(`${base_url}hub/api` + endpoint, location.origin);
|
||||
if (xsrfToken) {
|
||||
// add xsrf token to url parameter
|
||||
var sep = endpoint.indexOf("?") === -1 ? "?" : "&";
|
||||
suffix = sep + "_xsrf=" + xsrfToken;
|
||||
api_url.searchParams.set("_xsrf", xsrfToken);
|
||||
}
|
||||
return fetch(api_url + endpoint + suffix, {
|
||||
return fetch(api_url, {
|
||||
method: method,
|
||||
json: true,
|
||||
headers: {
|
||||
|
@@ -17,7 +17,7 @@ export const usePaginationParams = () => {
|
||||
}
|
||||
};
|
||||
const _setLimit = (params, limit) => {
|
||||
if (limit < 10) limit = 10;
|
||||
if (limit < 1) limit = 1;
|
||||
if (limit === window.api_page_limit) {
|
||||
params.delete("limit");
|
||||
} else {
|
||||
|
@@ -2,13 +2,16 @@ import { withProps } from "recompose";
|
||||
import { jhapiRequest } from "./jhapiUtil";
|
||||
|
||||
const withAPI = withProps(() => ({
|
||||
updateUsers: (offset, limit, name_filter) =>
|
||||
jhapiRequest(
|
||||
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
|
||||
name_filter || ""
|
||||
}`,
|
||||
"GET",
|
||||
).then((data) => data.json()),
|
||||
updateUsers: (options) => {
|
||||
let params = new URLSearchParams();
|
||||
params.set("include_stopped_servers", "1");
|
||||
for (let key in options) {
|
||||
params.set(key, options[key]);
|
||||
}
|
||||
return jhapiRequest(`/users?${params.toString()}`, "GET").then((data) =>
|
||||
data.json(),
|
||||
);
|
||||
},
|
||||
updateGroups: (offset, limit) =>
|
||||
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
|
||||
(data) => data.json(),
|
||||
|
@@ -352,6 +352,10 @@ class APIHandler(BaseHandler):
|
||||
if include_stopped_servers:
|
||||
# add any stopped servers in the db
|
||||
seen = set(servers.keys())
|
||||
if isinstance(user, orm.User):
|
||||
# need high-level User wrapper for spawner model
|
||||
# FIXME: this shouldn't be needed!
|
||||
user = self.users[user]
|
||||
for name, orm_spawner in user.orm_spawners.items():
|
||||
if name not in seen and scope_filter(orm_spawner, kind='server'):
|
||||
servers[name] = self.server_model(orm_spawner, user=user)
|
||||
|
@@ -1116,8 +1116,9 @@ async def test_search_on_admin_page(
|
||||
displaying = browser.get_by_text("Displaying")
|
||||
if users_count_db_filtered <= 50:
|
||||
await expect(filtered_list_on_page).to_have_count(users_count_db_filtered)
|
||||
start = 1 if users_count_db_filtered else 0
|
||||
await expect(displaying).to_contain_text(
|
||||
re.compile(f"1-{users_count_db_filtered}")
|
||||
re.compile(f"{start}-{users_count_db_filtered}")
|
||||
)
|
||||
# check that users names contain the search value in the filtered list
|
||||
for element in await filtered_list_on_page.get_by_test_id(
|
||||
|
Reference in New Issue
Block a user