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",
"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",

View File

@@ -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",

View File

@@ -7,6 +7,7 @@ import {
Button,
Col,
Row,
Form,
FormControl,
Card,
CardGroup,
@@ -32,21 +33,6 @@ 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);
@@ -59,6 +45,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 +64,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 +81,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 +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(() => {
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 +158,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);
})
@@ -417,42 +443,39 @@ 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"
<label title="only show active servers">
<Form.Check
inline
type="checkbox"
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 id="actions-header">Actions</th>
</tr>
@@ -490,7 +513,7 @@ const ServerDashboard = (props) => {
return res;
})
.then((res) => {
updateUsers(...slice)
updateUsersWithParams()
.then((data) => {
dispatchPageUpdate(data.items, data._pagination);
})
@@ -526,7 +549,7 @@ const ServerDashboard = (props) => {
return res;
})
.then((res) => {
updateUsers(...slice)
updateUsersWithParams()
.then((data) => {
dispatchPageUpdate(data.items, data._pagination);
})
@@ -590,30 +613,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 +643,9 @@ const SortHandler = (props) => {
};
SortHandler.propTypes = {
sorts: PropTypes.object,
callback: PropTypes.func,
currentSort: PropTypes.string,
setSort: PropTypes.func,
sortKey: PropTypes.string,
testid: PropTypes.string,
};

View File

@@ -46,3 +46,51 @@ tr.noborder > td {
.user-row .actions > * {
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";
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["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(),