From 1d0b6b9a627272dfc83f498f5745fbab05ec7423 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:47:33 +0100 Subject: [PATCH] Add user status filters --- .../web_ui/src/components/UsersFilters.tsx | 157 ++++++++++++++++++ packages/web_ui/src/components/UsersTable.tsx | 41 +---- packages/web_ui/src/style.css | 27 +++ 3 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 packages/web_ui/src/components/UsersFilters.tsx diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx new file mode 100644 index 00000000..34295d18 --- /dev/null +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -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(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, search?: boolean) { + const updated = { ...filter, ...next }; + setSelectedKeys([encodeFilter(updated)]); + confirm({ closeDropdown: false }); + } + + return ( +
e.stopPropagation()}> + + update({ search: e.target.value })} + onSearch={() => confirm({ closeDropdown: true })} + onClear={() => { + clearFilters?.({ closeDropdown: false }); + confirm({ closeDropdown: true }); + }} + /> + + + update({ admin: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Admin + + + + update({ whitelisted: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Whitelist + + + + update({ banned: segmentedToTriState(value) })} + options={[ + { icon: , value: "no", className: "seg-no" }, + { icon: , value: "any" }, + { icon: , value: "yes", className: "seg-yes" }, + ]} + /> + Banned + + +
+ ); + } + + const filterDropdownProps = { + onOpenChange: (open: boolean) => { + if (open) { + setTimeout(() => searchInput.current?.select(), 100); + } + }, + }; + + return { filterDropdown, filterDropdownProps }; +} diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index ec39a5d3..15b31f97 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -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(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) => ( ), - 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"], - }) => ( -
e.stopPropagation()}> - setSelectedKeys([e.target.value])} - onSearch={() => confirm({ closeDropdown: false })} - onClear={() => { - clearFilters?.({ closeDropdown: false }); - confirm({ closeDropdown: true }); - }} - /> -
- ), + onFilter: onFilterUser, + filterDropdownProps, + filterDropdown, }, { title: "Roles", diff --git a/packages/web_ui/src/style.css b/packages/web_ui/src/style.css index 59be1d2b..b1f53739 100644 --- a/packages/web_ui/src/style.css +++ b/packages/web_ui/src/style.css @@ -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; +}