server-side sorting of admin page

removes in-page sort, which removes sort by server name, sort by running

Running column switches from sort to filter, matching the `?state` query parameter in the API

needs some CSS on the column widths to avoid jumps when toggling active servers
This commit is contained in:
Min RK
2024-03-06 23:16:43 +01:00
parent 943e4a7072
commit 78a796cea6
5 changed files with 154 additions and 81 deletions

13
jsx/package-lock.json generated
View File

@@ -14,7 +14,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-bootstrap": "^2.7.4", "react-bootstrap": "^2.10.1",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-multi-select-component": "^4.3.4", "react-multi-select-component": "^4.3.4",
@@ -8209,13 +8209,14 @@
} }
}, },
"node_modules/react-bootstrap": { "node_modules/react-bootstrap": {
"version": "2.7.4", "version": "2.10.1",
"license": "MIT", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.1.tgz",
"integrity": "sha512-J3OpRZIvCTQK+Tg/jOkRUvpYLHMdGeU9KqFUBQrV0d/Qr/3nsINpiOJyZMWnM5SJ3ctZdhPA6eCIKpEJR3Ellg==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0", "@babel/runtime": "^7.22.5",
"@restart/hooks": "^0.4.9", "@restart/hooks": "^0.4.9",
"@restart/ui": "^1.6.3", "@restart/ui": "^1.6.6",
"@types/react-transition-group": "^4.4.5", "@types/react-transition-group": "^4.4.6",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"dom-helpers": "^5.2.1", "dom-helpers": "^5.2.1",
"invariant": "^2.2.4", "invariant": "^2.2.4",

View File

@@ -34,7 +34,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-bootstrap": "^2.7.4", "react-bootstrap": "^2.10.1",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-multi-select-component": "^4.3.4", "react-multi-select-component": "^4.3.4",

View File

@@ -7,6 +7,7 @@ import {
Button, Button,
Col, Col,
Row, Row,
Form,
FormControl, FormControl,
Card, Card,
CardGroup, CardGroup,
@@ -32,21 +33,6 @@ RowListItem.propTypes = {
const ServerDashboard = (props) => { const ServerDashboard = (props) => {
const base_url = window.base_url || "/"; const base_url = window.base_url || "/";
const [searchParams, setSearchParams] = useSearchParams(); 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 [errorAlert, setErrorAlert] = useState(null);
const [sortMethod, setSortMethod] = useState(null); const [sortMethod, setSortMethod] = useState(null);
@@ -59,6 +45,8 @@ const ServerDashboard = (props) => {
usePaginationParams(); usePaginationParams();
const name_filter = searchParams.get("name_filter") || ""; 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; const total = user_page ? user_page.total : undefined;
@@ -76,7 +64,13 @@ const ServerDashboard = (props) => {
} = props; } = props;
const dispatchPageUpdate = (data, page) => { 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); setPagination(page);
// persist user data, triggers rerender
dispatch({ dispatch({
type: "USER_PAGE", type: "USER_PAGE",
value: { value: {
@@ -87,6 +81,8 @@ const ServerDashboard = (props) => {
}; };
const setNameFilter = (new_name_filter) => { const setNameFilter = (new_name_filter) => {
// persist ?name_filter parameter
// store in url param, clear when value is empty
setSearchParams((params) => { setSearchParams((params) => {
// clear offset when name filter changes // clear offset when name filter changes
if (new_name_filter !== name_filter) { if (new_name_filter !== name_filter) {
@@ -101,26 +97,56 @@ 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 = (state_filter) => {
// persist ?state filter
// store in url param, clear when value is default ('')
setSearchParams((params) => {
if (!state_filter) {
params.delete("state");
} else {
params.set("state", state_filter);
}
return params;
});
};
// the callback to update the displayed user list
const updateUsersWithParams = () =>
updateUsers({
offset,
limit,
name_filter,
sort,
state: state_filter,
});
useEffect(() => { useEffect(() => {
updateUsers(offset, limit, name_filter) updateUsersWithParams()
.then((data) => dispatchPageUpdate(data.items, data._pagination)) .then((data) => dispatchPageUpdate(data.items, data._pagination))
.catch((err) => setErrorAlert("Failed to update user list.")); .catch((err) => setErrorAlert("Failed to update user list."));
}, [offset, limit, name_filter]); }, [offset, limit, name_filter, sort, state_filter]);
if (!user_data || !user_page) { if (!user_data || !user_page) {
return <div data-testid="no-show"></div>; return <div data-testid="no-show"></div>;
} }
var slice = [offset, limit, name_filter];
const handleSearch = debounce(async (event) => { const handleSearch = debounce(async (event) => {
setNameFilter(event.target.value); setNameFilter(event.target.value);
}, 300); }, 300);
if (sortMethod != null) {
user_data = sortMethod(user_data);
}
const ServerButton = ({ server, user, action, name, extraClass }) => { const ServerButton = ({ server, user, action, name, extraClass }) => {
var [isDisabled, setIsDisabled] = useState(false); var [isDisabled, setIsDisabled] = useState(false);
return ( return (
@@ -132,7 +158,7 @@ const ServerDashboard = (props) => {
action(user.name, server.name) action(user.name, server.name)
.then((res) => { .then((res) => {
if (res.status < 300) { if (res.status < 300) {
updateUsers(...slice) updateUsersWithParams()
.then((data) => { .then((data) => {
dispatchPageUpdate(data.items, data._pagination); dispatchPageUpdate(data.items, data._pagination);
}) })
@@ -417,42 +443,39 @@ const ServerDashboard = (props) => {
<th id="user-header"> <th id="user-header">
User{" "} User{" "}
<SortHandler <SortHandler
sorts={{ asc: usernameAsc, desc: usernameDesc }} currentSort={sort}
callback={(method) => setSortMethod(() => method)} setSort={setSort}
sortKey="name"
testid="user-sort" testid="user-sort"
/> />
</th> </th>
<th id="admin-header"> <th id="admin-header">Admin</th>
Admin{" "} <th id="server-header">Server</th>
<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="last-activity-header"> <th id="last-activity-header">
Last Activity{" "} Last Activity{" "}
<SortHandler <SortHandler
sorts={{ asc: dateAsc, desc: dateDesc }} currentSort={sort}
callback={(method) => setSortMethod(() => method)} setSort={setSort}
sortKey="last_activity"
testid="last-activity-sort" testid="last-activity-sort"
/> />
</th> </th>
<th id="running-status-header"> <th id="running-status-header">
Running{" "} <label title="only show active servers">
<SortHandler <Form.Check
sorts={{ asc: runningAsc, desc: runningDesc }} inline
callback={(method) => setSortMethod(() => method)} type="checkbox"
testid="running-status-sort" name="active_servers"
/> aria-label="only show active servers"
checked={state_filter == "active"}
onChange={(event) => {
setStateFilter(event.target.checked ? "active" : null);
}}
/>
{state_filter == "active"
? " Active servers"
: " All servers"}
</label>
</th> </th>
<th id="actions-header">Actions</th> <th id="actions-header">Actions</th>
</tr> </tr>
@@ -490,7 +513,7 @@ const ServerDashboard = (props) => {
return res; return res;
}) })
.then((res) => { .then((res) => {
updateUsers(...slice) updateUsersWithParams()
.then((data) => { .then((data) => {
dispatchPageUpdate(data.items, data._pagination); dispatchPageUpdate(data.items, data._pagination);
}) })
@@ -526,7 +549,7 @@ const ServerDashboard = (props) => {
return res; return res;
}) })
.then((res) => { .then((res) => {
updateUsers(...slice) updateUsersWithParams()
.then((data) => { .then((data) => {
dispatchPageUpdate(data.items, data._pagination); dispatchPageUpdate(data.items, data._pagination);
}) })
@@ -590,30 +613,27 @@ ServerDashboard.propTypes = {
}; };
const SortHandler = (props) => { const SortHandler = (props) => {
var { sorts, callback, testid } = props; const { currentSort, setSort, sortKey, testid } = props;
var [direction, setDirection] = useState(undefined);
const currentlySorted = currentSort && currentSort.endsWith(sortKey);
const descending = currentSort && currentSort.startsWith("-");
return ( return (
<div <div
className="sort-icon" className="sort-icon"
data-testid={testid} data-testid={testid}
onClick={() => { onClick={() => {
if (!direction) { if (!currentlySorted) {
callback(sorts.desc); setSort(sortKey);
setDirection("desc"); } else if (descending) {
} else if (direction == "asc") { setSort(sortKey);
callback(sorts.desc);
setDirection("desc");
} else { } else {
callback(sorts.asc); setSort("-" + sortKey);
setDirection("asc");
} }
}} }}
> >
{!direction ? ( {!currentlySorted ? (
<FaSort /> <FaSort />
) : direction == "asc" ? ( ) : descending ? (
<FaSortDown /> <FaSortDown />
) : ( ) : (
<FaSortUp /> <FaSortUp />
@@ -623,8 +643,9 @@ const SortHandler = (props) => {
}; };
SortHandler.propTypes = { SortHandler.propTypes = {
sorts: PropTypes.object, currentSort: PropTypes.string,
callback: PropTypes.func, setSort: PropTypes.func,
sortKey: PropTypes.string,
testid: PropTypes.string, testid: PropTypes.string,
}; };

View File

@@ -46,3 +46,51 @@ tr.noborder > td {
.user-row .actions > * { .user-row .actions > * {
margin-right: 5px; margin-right: 5px;
} }
.form-check-inline {
/* inline form check doesn't get inline css
I _think_ this is because our bootstrap css is outdated
*/
display: inline-block;
}
/* 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 label {
/* clear margin-bottom to keep label on baseline */
margin-bottom: 0px;
}
.admin-table-head #user-header {
}
.admin-table-head #admin-header {
width: 64px;
}
.admin-table-head #server-header {
}
.admin-table-head #last-activity-header {
width: 180px;
}
.admin-table-head #running-status-header {
width: 300px;
}
.admin-table-head #actions-header {
width: 80px;
}
/* vertical stack server buttons on small windows */
@media (max-width: 991px) {
.admin-table-head #running-status-header {
width: 140px;
}
}

View File

@@ -2,13 +2,16 @@ import { withProps } from "recompose";
import { jhapiRequest } from "./jhapiUtil"; import { jhapiRequest } from "./jhapiUtil";
const withAPI = withProps(() => ({ const withAPI = withProps(() => ({
updateUsers: (offset, limit, name_filter) => updateUsers: (options) => {
jhapiRequest( let params = new URLSearchParams();
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${ params["include_stopped_servers"] = "1";
name_filter || "" for (let key in options) {
}`, params.set(key, options[key]);
"GET", }
).then((data) => data.json()), return jhapiRequest(`/users?${params.toString()}`, "GET").then((data) =>
data.json(),
);
},
updateGroups: (offset, limit) => updateGroups: (offset, limit) =>
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then( jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
(data) => data.json(), (data) => data.json(),