mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Make all table search live and only write the URL on close
Add a live filter override to useTableQueryState: setFilter updates the filter immediately (so every table's search filters as you type) without touching the URL, and commitFilter writes it to the URL when the filter dropdown closes. useColumnSearch and useUserFilter now filter live via setFilter and commit on close, so the Users table no longer rewrites the URL on every keystroke and the other tables gain live search. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1f2730a64f
commit
bb4c82ea5a
10 files changed
+95
-31
No files matched your search
@@ -210,7 +210,7 @@ export default function HostsPage() {
|
||||
const tableState = useTableQueryState<lib.HostDetails>({
|
||||
namespace: "host", defaultSortKey: "name",
|
||||
});
|
||||
const nameSearch = useColumnSearch<lib.HostDetails>(host => host.name, "Search hosts");
|
||||
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
return <PageLayout nav={[{ name: "Hosts" }]}>
|
||||
|
||||
@@ -32,7 +32,9 @@ export default function InstanceList(props: InstanceListProps) {
|
||||
const tableState = useTableQueryState<lib.InstanceDetails>({
|
||||
namespace: "instance", defaultSortKey: "name",
|
||||
});
|
||||
const nameSearch = useColumnSearch<lib.InstanceDetails>(instance => instance.name, "Search instances");
|
||||
const nameSearch = useColumnSearch<lib.InstanceDetails>(
|
||||
tableState, "name", instance => instance.name, "Search instances"
|
||||
);
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
function hostName(hostId?: number) {
|
||||
|
||||
@@ -518,8 +518,10 @@ export default function ModsPage() {
|
||||
const modTable = useTableQueryState<lib.ModInfo>({
|
||||
namespace: "mod", defaultSortKey: "title",
|
||||
});
|
||||
const modPackNameSearch = useColumnSearch<lib.ModPack>(modPack => modPack.name, "Search mod packs");
|
||||
const modNameSearch = useColumnSearch<lib.ModInfo>(mod => mod.title, "Search mods");
|
||||
const modPackNameSearch = useColumnSearch<lib.ModPack>(
|
||||
modPackTable, "name", modPack => modPack.name, "Search mod packs"
|
||||
);
|
||||
const modNameSearch = useColumnSearch<lib.ModInfo>(modTable, "title", mod => mod.title, "Search mods");
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
function actions(mod: lib.ModInfo) {
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function PluginsPage() {
|
||||
namespace: "plugin", defaultSortKey: "name",
|
||||
});
|
||||
const nameSearch = useColumnSearch<PluginRow>(
|
||||
plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins"
|
||||
tableState, "name", plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins"
|
||||
);
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function RolesPage() {
|
||||
let account = useAccount();
|
||||
const [roles] = useRoles();
|
||||
const tableState = useTableQueryState<lib.Role>({ namespace: "role" });
|
||||
const nameSearch = useColumnSearch<lib.Role>(role => role.name, "Search roles");
|
||||
const nameSearch = useColumnSearch<lib.Role>(tableState, "name", role => role.name, "Search roles");
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
return <PageLayout nav={[{ name: "Roles" }]}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FilterDropdownProps } from "antd/es/table/interface";
|
||||
import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { UserDetails } from "@clusterio/lib";
|
||||
import type { FilterCodec } from "../util/tableQuery";
|
||||
import type { TableQueryState } from "../util/useTableQueryState";
|
||||
const { Text } = Typography;
|
||||
|
||||
export interface UserFilter {
|
||||
@@ -118,27 +119,29 @@ export function Username({
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserFilter(withStatus?: boolean) {
|
||||
export function useUserFilter(
|
||||
tableState: TableQueryState<UserDetails>,
|
||||
columnKey: string,
|
||||
withStatus?: boolean,
|
||||
) {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
function filterDropdown({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
close,
|
||||
}: FilterDropdownProps) {
|
||||
const filter: UserFilter = selectedKeys[0]
|
||||
? decodeFilter(String(selectedKeys[0]))
|
||||
: {};
|
||||
|
||||
// Update the live filter as the user types/toggles; the URL is only written
|
||||
// when the dropdown closes (see filterDropdownProps.onOpenChange below).
|
||||
function update(next: Partial<UserFilter>) {
|
||||
const encoded = encodeFilter({ ...filter, ...next });
|
||||
if (encoded === "{}") {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
} else {
|
||||
setSelectedKeys([encoded]);
|
||||
}
|
||||
confirm({ closeDropdown: false });
|
||||
const values = encoded === "{}" ? null : [encoded];
|
||||
setSelectedKeys(values ?? []);
|
||||
tableState.setFilter(columnKey, values);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -149,12 +152,9 @@ export function useUserFilter(withStatus?: boolean) {
|
||||
placeholder="Search username"
|
||||
value={filter.search}
|
||||
allowClear
|
||||
onSearch={() => confirm({ closeDropdown: true })}
|
||||
onSearch={() => close()}
|
||||
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
|
||||
onClear={() => {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
confirm({ closeDropdown: true });
|
||||
}}
|
||||
onClear={() => update({ search: undefined })}
|
||||
/>
|
||||
|
||||
{withStatus &&<>
|
||||
@@ -209,6 +209,8 @@ export function useUserFilter(withStatus?: boolean) {
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => searchInput.current?.select(), 100);
|
||||
} else {
|
||||
tableState.commitFilter(columnKey);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function UsersTable(
|
||||
});
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
|
||||
const {filterDropdown, filterDropdownProps} = useUserFilter(tableState, "name", true);
|
||||
|
||||
const data = [...users.values()];
|
||||
const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id }));
|
||||
|
||||
@@ -3,34 +3,50 @@ import { Input, InputRef } from "antd";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import type { FilterDropdownProps } from "antd/es/table/interface";
|
||||
|
||||
import type { TableQueryState } from "./useTableQueryState";
|
||||
|
||||
/**
|
||||
* Column props for a free-text search filter rendered in the column header
|
||||
* dropdown (the same style as the Users table). Spread the result onto a
|
||||
* column; the search value is persisted through the table's filter state by
|
||||
* {@link useTableQueryState}.
|
||||
* dropdown (the same style as the Users table). Spread the result onto a column
|
||||
* whose `filteredValue` is `tableState.filteredValue(columnKey)`.
|
||||
*
|
||||
* Filtering is live: the table updates as you type via {@link TableQueryState.setFilter},
|
||||
* and the value is only written to the URL when the dropdown closes (via
|
||||
* {@link TableQueryState.commitFilter}).
|
||||
*
|
||||
* @param tableState The table's query state.
|
||||
* @param columnKey Key of the column this search filters (matches its filteredValue key).
|
||||
* @param getText Returns the searchable text for a row.
|
||||
* @param placeholder Placeholder shown in the search box.
|
||||
*/
|
||||
export default function useColumnSearch<T>(getText: (record: T) => string, placeholder = "Search") {
|
||||
export default function useColumnSearch<T>(
|
||||
tableState: TableQueryState<T>,
|
||||
columnKey: string,
|
||||
getText: (record: T) => string,
|
||||
placeholder = "Search",
|
||||
) {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
|
||||
return {
|
||||
filterIcon: (filtered: boolean) => (
|
||||
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
|
||||
),
|
||||
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: FilterDropdownProps) => (
|
||||
filterDropdown: ({ selectedKeys, setSelectedKeys, close }: FilterDropdownProps) => (
|
||||
<div style={{ padding: 8, width: 240 }} onKeyDown={e => e.stopPropagation()}>
|
||||
<Input.Search
|
||||
ref={searchInput}
|
||||
placeholder={placeholder}
|
||||
value={selectedKeys[0] as string | undefined}
|
||||
allowClear
|
||||
onChange={e => setSelectedKeys(e.target.value ? [e.target.value] : [])}
|
||||
onSearch={() => confirm({ closeDropdown: true })}
|
||||
onChange={e => {
|
||||
const values = e.target.value ? [e.target.value] : [];
|
||||
setSelectedKeys(values);
|
||||
tableState.setFilter(columnKey, values.length ? values : null);
|
||||
}}
|
||||
onSearch={() => close()}
|
||||
onClear={() => {
|
||||
clearFilters?.({ closeDropdown: false });
|
||||
confirm({ closeDropdown: true });
|
||||
setSelectedKeys([]);
|
||||
tableState.setFilter(columnKey, null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -39,6 +55,8 @@ export default function useColumnSearch<T>(getText: (record: T) => string, place
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => searchInput.current?.select(), 100);
|
||||
} else {
|
||||
tableState.commitFilter(columnKey);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { TablePaginationConfig } from "antd";
|
||||
import type { FilterValue, SorterResult } from "antd/es/table/interface";
|
||||
@@ -32,6 +33,10 @@ export interface TableQueryState<T> {
|
||||
sortOrder: (columnKey: string) => SortOrder | null;
|
||||
/** Controlled `filteredValue` for the column with this key (null when no filter is active). */
|
||||
filteredValue: (columnKey: string) => string[] | null;
|
||||
/** Set a column's live filter — filters the table immediately without writing the URL. */
|
||||
setFilter: (columnKey: string, values: string[] | null) => void;
|
||||
/** Commit a column's live filter to the URL; call when its filter dropdown closes. */
|
||||
commitFilter: (columnKey: string) => void;
|
||||
/** Controlled pagination ({@link current}/{@link pageSize}), or `false` when disabled. */
|
||||
pagination: false | TablePaginationConfig;
|
||||
}
|
||||
@@ -52,6 +57,14 @@ export default function useTableQueryState<T>(options: TableQueryStateOptions):
|
||||
const defaultPageSize = paginationOptions?.defaultPageSize ?? 10;
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
// Live filter overrides let the table filter as the user types without writing the
|
||||
// URL on every keystroke; they are committed to the URL when the filter dropdown
|
||||
// closes (see commitFilter) and cleared whenever the URL changes (commit, back/forward).
|
||||
const [liveFilters, setLiveFilters] = useState<Record<string, string[] | null>>({});
|
||||
const paramsString = params.toString();
|
||||
useEffect(() => {
|
||||
setLiveFilters({});
|
||||
}, [paramsString]);
|
||||
|
||||
const sort = parseSort(params, prefix, defaultSortKey, defaultSortOrder);
|
||||
const filters = parseFilters(params, prefix);
|
||||
@@ -90,10 +103,35 @@ export default function useTableQueryState<T>(options: TableQueryStateOptions):
|
||||
},
|
||||
|
||||
filteredValue(columnKey) {
|
||||
if (columnKey in liveFilters) {
|
||||
return liveFilters[columnKey];
|
||||
}
|
||||
const codec = codecs[columnKey];
|
||||
return codec ? codec.decode(params, prefix) : (filters[columnKey] ?? null);
|
||||
},
|
||||
|
||||
setFilter(columnKey, values) {
|
||||
setLiveFilters(prev => ({ ...prev, [columnKey]: values }));
|
||||
},
|
||||
|
||||
commitFilter(columnKey) {
|
||||
// Only write the URL when a live edit is pending for this column.
|
||||
if (!(columnKey in liveFilters)) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(params);
|
||||
const codec = codecs[columnKey];
|
||||
if (codec) {
|
||||
codec.encode(next, liveFilters[columnKey], prefix);
|
||||
} else {
|
||||
applyFilters(next, prefix, { [columnKey]: liveFilters[columnKey] });
|
||||
}
|
||||
// Avoid pushing a duplicate history entry when nothing actually changed.
|
||||
if (next.toString() !== paramsString) {
|
||||
setParams(next);
|
||||
}
|
||||
},
|
||||
|
||||
pagination: paginationEnabled && page
|
||||
? { current: page.page, pageSize: page.pageSize }
|
||||
: false,
|
||||
|
||||
@@ -39,7 +39,9 @@ function StoragePage() {
|
||||
const tableState = useTableQueryState<[string, Item]>({
|
||||
namespace: "storage", defaultSortKey: "quantity", defaultSortOrder: "descend", pagination: false,
|
||||
});
|
||||
const resourceSearch = useColumnSearch<[string, Item]>(item => getLocaleName(item[1].name), "Search");
|
||||
const resourceSearch = useColumnSearch<[string, Item]>(
|
||||
tableState, "resource", item => getLocaleName(item[1].name), "Search"
|
||||
);
|
||||
|
||||
function getLocaleName(itemName: string) {
|
||||
let localeName = itemName;
|
||||
|
||||
Reference in new issue
Block a user