From bb4c82ea5a2eb9efbb55fa269b98e464866683a0 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:14:32 +0000 Subject: [PATCH] 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) --- packages/web_ui/src/components/HostsPage.tsx | 2 +- .../web_ui/src/components/InstanceList.tsx | 4 +- packages/web_ui/src/components/ModsPage.tsx | 6 ++- .../web_ui/src/components/PluginsPage.tsx | 2 +- packages/web_ui/src/components/RolesPage.tsx | 2 +- .../web_ui/src/components/UsersFilters.tsx | 30 ++++++++------- packages/web_ui/src/components/UsersTable.tsx | 2 +- packages/web_ui/src/util/useColumnSearch.tsx | 36 +++++++++++++----- .../web_ui/src/util/useTableQueryState.ts | 38 +++++++++++++++++++ plugins/subspace_storage/web/index.tsx | 4 +- 10 files changed, 95 insertions(+), 31 deletions(-) diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 9eca290c..2176e6ec 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -210,7 +210,7 @@ export default function HostsPage() { const tableState = useTableQueryState({ namespace: "host", defaultSortKey: "name", }); - const nameSearch = useColumnSearch(host => host.name, "Search hosts"); + const nameSearch = useColumnSearch(tableState, "name", host => host.name, "Search hosts"); const rowNav = useRowNavigation(); return diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index b3adb4bd..d0806b4d 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -32,7 +32,9 @@ export default function InstanceList(props: InstanceListProps) { const tableState = useTableQueryState({ namespace: "instance", defaultSortKey: "name", }); - const nameSearch = useColumnSearch(instance => instance.name, "Search instances"); + const nameSearch = useColumnSearch( + tableState, "name", instance => instance.name, "Search instances" + ); const rowNav = useRowNavigation(); function hostName(hostId?: number) { diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index a6af79fe..34963f4e 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -518,8 +518,10 @@ export default function ModsPage() { const modTable = useTableQueryState({ namespace: "mod", defaultSortKey: "title", }); - const modPackNameSearch = useColumnSearch(modPack => modPack.name, "Search mod packs"); - const modNameSearch = useColumnSearch(mod => mod.title, "Search mods"); + const modPackNameSearch = useColumnSearch( + modPackTable, "name", modPack => modPack.name, "Search mod packs" + ); + const modNameSearch = useColumnSearch(modTable, "title", mod => mod.title, "Search mods"); const rowNav = useRowNavigation(); function actions(mod: lib.ModInfo) { diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index 1a8960f1..399a92de 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -30,7 +30,7 @@ export default function PluginsPage() { namespace: "plugin", defaultSortKey: "name", }); const nameSearch = useColumnSearch( - 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(); diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index 24f2c5eb..e3c9dbf9 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -67,7 +67,7 @@ export default function RolesPage() { let account = useAccount(); const [roles] = useRoles(); const tableState = useTableQueryState({ namespace: "role" }); - const nameSearch = useColumnSearch(role => role.name, "Search roles"); + const nameSearch = useColumnSearch(tableState, "name", role => role.name, "Search roles"); const rowNav = useRowNavigation(); return diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index 4c32ee78..ca54014c 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -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, + columnKey: string, + withStatus?: boolean, +) { const searchInput = useRef(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) { 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); } }, }; diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 6e2b41c7..8cbd2580 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -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 })); diff --git a/packages/web_ui/src/util/useColumnSearch.tsx b/packages/web_ui/src/util/useColumnSearch.tsx index 676a3b4c..4fe3451d 100644 --- a/packages/web_ui/src/util/useColumnSearch.tsx +++ b/packages/web_ui/src/util/useColumnSearch.tsx @@ -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(getText: (record: T) => string, placeholder = "Search") { +export default function useColumnSearch( + tableState: TableQueryState, + columnKey: string, + getText: (record: T) => string, + placeholder = "Search", +) { const searchInput = useRef(null); return { filterIcon: (filtered: boolean) => ( ), - filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: FilterDropdownProps) => ( + filterDropdown: ({ selectedKeys, setSelectedKeys, close }: FilterDropdownProps) => (
e.stopPropagation()}> 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); }} />
@@ -39,6 +55,8 @@ export default function useColumnSearch(getText: (record: T) => string, place onOpenChange: (open: boolean) => { if (open) { setTimeout(() => searchInput.current?.select(), 100); + } else { + tableState.commitFilter(columnKey); } }, }, diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts index dad0be56..b7e43e3e 100644 --- a/packages/web_ui/src/util/useTableQueryState.ts +++ b/packages/web_ui/src/util/useTableQueryState.ts @@ -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 { 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(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>({}); + 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(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, diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 3eb72a02..2ead807c 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -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;