mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Add user status filters
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user