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;