Add user status filters

This commit is contained in:
Cooldude2606 committed 2026-06-01 21:47:33 +01:00
1 parent f2ca465ec6
commit 1d0b6b9a62
3 files changed
+193 -32

No files matched your search

@@ -0,0 +1,157 @@
import React, { useRef } from "react";
import { InputRef, Input, Space, Segmented, Button, Typography } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons";
import { UserDetails } from "@clusterio/lib";
const { Text } = Typography;
export interface UserFilter {
search?: string;
admin?: boolean;
whitelisted?: boolean;
banned?: boolean;
}
function encodeFilter(value: UserFilter): string {
return JSON.stringify(value);
}
function decodeFilter(value: string): UserFilter {
try {
return JSON.parse(value);
} catch {
return {};
}
}
function triStateToSegmented(value?: boolean) {
if (value === true) { return "yes"; }
if (value === false) { return "no"; }
return "any";
}
function segmentedToTriState(value: string): boolean | undefined {
if (value === "yes") { return true; }
if (value === "no") { return false; }
return undefined;
}
function matchesTriState(value: boolean, filter?: boolean) {
return filter === undefined || value === filter;
}
export function onFilterUser(
value: string | number | boolean,
record: UserDetails
) {
const filter = decodeFilter(String(value));
// Name filter
if (filter.search) {
if (!record.name.toLowerCase().includes(filter.search.toLowerCase())) {
return false;
}
}
// Status filter
return matchesTriState(record.isAdmin, filter.admin)
&& matchesTriState(record.isWhitelisted, filter.whitelisted)
&& matchesTriState(record.isBanned, filter.banned);
}
export function useUserFilter() {
const searchInput = useRef<InputRef>(null);
function filterDropdown({
selectedKeys,
setSelectedKeys,
confirm,
clearFilters,
}: {
selectedKeys: string[];
setSelectedKeys: (keys: string[]) => void;
confirm: FilterDropdownProps["confirm"];
clearFilters: FilterDropdownProps["clearFilters"];
}) {
const filter: UserFilter = selectedKeys[0]
? decodeFilter(selectedKeys[0])
: {};
function update(next: Partial<UserFilter>, search?: boolean) {
const updated = { ...filter, ...next };
setSelectedKeys([encodeFilter(updated)]);
confirm({ closeDropdown: false });
}
return (
<div style={{ padding: 8, width: 240 }} onKeyDown={(e) => e.stopPropagation()}>
<Space direction="vertical" style={{ width: "100%" }}>
<Input.Search
ref={searchInput}
placeholder="Search username"
value={filter.search}
allowClear
onChange={(e) => update({ search: e.target.value })}
onSearch={() => confirm({ closeDropdown: true })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
}}
/>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.admin)}
onChange={value => update({ admin: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Admin</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.whitelisted)}
onChange={value => update({ whitelisted: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Whitelist</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.banned)}
onChange={value => update({ banned: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Banned</Text>
</Space>
</Space>
</div>
);
}
const filterDropdownProps = {
onOpenChange: (open: boolean) => {
if (open) {
setTimeout(() => searchInput.current?.select(), 100);
}
},
};
return { filterDropdown, filterDropdownProps };
}
+9 -32
View File
@@ -1,6 +1,5 @@
import React, { useRef } from "react";
import { Table, Tag, Space, Input, InputRef } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import React from "react";
import { Table, Tag, Space } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
@@ -15,6 +14,8 @@ import {
sortLastSeen,
useUsers,
} from "../model/user";
import { onFilterUser, useUserFilter } from "./UsersFilters";
import Link from "./Link";
export interface UsersTableProps {
@@ -37,10 +38,10 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base"
export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
const [roles] = useRoles();
const navigate = useNavigate();
const searchInput = useRef<InputRef>(null);
const {filterDropdown, filterDropdownProps} = useUserFilter();
const [users] = useUsers();
const data = [...users.values()];
// Determine online predicate based on instanceId
@@ -73,33 +74,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
onFilter: (value: string | number | boolean, record: lib.UserDetails) => record.name
.toLowerCase()
.includes((value as string).toLowerCase()),
filterDropdownProps: {
onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100),
},
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: {
selectedKeys: string[],
setSelectedKeys: (keys: string[]) => void,
confirm: FilterDropdownProps["confirm"],
clearFilters: FilterDropdownProps["clearFilters"],
}) => (
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
<Input.Search
allowClear
ref={searchInput}
placeholder={"Search username"}
value={selectedKeys[0]}
onChange={(e) => setSelectedKeys([e.target.value])}
onSearch={() => confirm({ closeDropdown: false })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
}}
/>
</div>
),
onFilter: onFilterUser,
filterDropdownProps,
filterDropdown,
},
{
title: "Roles",
+27
View File
@@ -409,3 +409,30 @@ tr.ant-table-row.deleted {
border-radius: 8px;
max-width: 384px;
}
/**
* Tri-state segmented control:
* represents include, exclude, or no filter
*/
.seg-yes {
color: #389e0d;
}
.seg-no {
color: #cf1322;
}
.ant-segmented-item-selected.seg-yes {
background-color: #389e0d !important;
color: white !important;
}
.ant-segmented-item-selected.seg-no {
background-color: #cf1322 !important;
color: white !important;
}
.seg-yes:hover {
background-color: #306317;
}
.seg-no:hover {
background-color: #7a2e2f;
}