mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
feat(ui): introduce BaseSearchPanel popover and migrate connections search
This commit is contained in:
279
src/components/base/base-search-panel.tsx
Normal file
279
src/components/base/base-search-panel.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { CheckRounded, FilterListRounded } from "@mui/icons-material";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
List,
|
||||
ListItemButton,
|
||||
Popover,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { type ComponentProps, useRef } from "react";
|
||||
|
||||
import { BaseSearchBox } from "./base-search-box";
|
||||
import { BaseStyledTextField } from "./base-styled-text-field";
|
||||
|
||||
export type BaseSearchPanelField<T extends string> = {
|
||||
key: T;
|
||||
label: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
type BaseSearchBoxProps = ComponentProps<typeof BaseSearchBox>;
|
||||
|
||||
type BaseSearchPanelProps<T extends string> = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSearch: BaseSearchBoxProps["onSearch"];
|
||||
searchBoxProps?: Omit<BaseSearchBoxProps, "onSearch" | "placeholder">;
|
||||
searchPlaceholder?: string;
|
||||
filterLabel?: string;
|
||||
title?: string;
|
||||
fields: BaseSearchPanelField<T>[];
|
||||
activeField: T;
|
||||
onActiveFieldChange: (field: T) => void;
|
||||
options: string[];
|
||||
isOptionSelected: (option: string) => boolean;
|
||||
onToggleOption: (option: string) => void;
|
||||
searchValue: string;
|
||||
onSearchValueChange: (value: string) => void;
|
||||
onSearchSubmit?: (value: string) => void;
|
||||
emptyText?: string;
|
||||
clearLabel?: string;
|
||||
clearDisabled?: boolean;
|
||||
onClear?: () => void;
|
||||
showIndicator?: boolean;
|
||||
};
|
||||
|
||||
export const BaseSearchPanel = <T extends string>({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSearch,
|
||||
searchBoxProps,
|
||||
searchPlaceholder,
|
||||
filterLabel,
|
||||
title,
|
||||
fields,
|
||||
activeField,
|
||||
onActiveFieldChange,
|
||||
options,
|
||||
isOptionSelected,
|
||||
onToggleOption,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
onSearchSubmit,
|
||||
emptyText,
|
||||
clearLabel,
|
||||
clearDisabled,
|
||||
onClear,
|
||||
showIndicator,
|
||||
}: BaseSearchPanelProps<T>) => {
|
||||
const anchorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
onOpenChange(!open);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const anchorWidth = anchorRef.current?.clientWidth;
|
||||
const placeholderProps = searchPlaceholder
|
||||
? { placeholder: searchPlaceholder }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
ref={anchorRef}
|
||||
sx={{ display: "flex", alignItems: "center", gap: 1, width: "100%" }}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<BaseSearchBox
|
||||
onSearch={onSearch}
|
||||
{...placeholderProps}
|
||||
{...searchBoxProps}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title={filterLabel ?? ""}>
|
||||
<Badge
|
||||
color="primary"
|
||||
variant="dot"
|
||||
overlap="circular"
|
||||
invisible={!showIndicator}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={handleToggleOpen}
|
||||
aria-label={filterLabel}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<FilterListRounded />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
mt: 1,
|
||||
width: anchorWidth,
|
||||
minWidth: 520,
|
||||
maxWidth: "90vw",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">{title}</Typography>
|
||||
{onClear && clearLabel ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={onClear}
|
||||
disabled={clearDisabled}
|
||||
>
|
||||
{clearLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", minHeight: 260, maxHeight: 360 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 180,
|
||||
borderRight: "1px solid",
|
||||
borderColor: "divider",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<List dense disablePadding>
|
||||
{fields.map((field) => (
|
||||
<ListItemButton
|
||||
key={field.key}
|
||||
selected={field.key === activeField}
|
||||
onClick={() => onActiveFieldChange(field.key)}
|
||||
sx={{ px: 1.25, py: 0.75 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{field.label}</Typography>
|
||||
{field.count ? (
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 20,
|
||||
px: 0.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: "action.selected",
|
||||
color: "text.secondary",
|
||||
fontSize: 12,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{field.count}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<BaseStyledTextField
|
||||
value={searchValue}
|
||||
{...placeholderProps}
|
||||
onChange={(event) =>
|
||||
onSearchValueChange(event.target.value ?? "")
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || !onSearchSubmit) return;
|
||||
event.preventDefault();
|
||||
onSearchSubmit(searchValue);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: "auto" }}>
|
||||
{options.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ px: 1.5, py: 2, color: "text.secondary" }}
|
||||
>
|
||||
{emptyText}
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense disablePadding>
|
||||
{options.map((option) => {
|
||||
const selected = isOptionSelected(option);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={option}
|
||||
selected={selected}
|
||||
onClick={() => onToggleOption(option)}
|
||||
sx={{ px: 1.5, py: 0.75 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" noWrap>
|
||||
{option}
|
||||
</Typography>
|
||||
{selected ? (
|
||||
<CheckRounded
|
||||
fontSize="small"
|
||||
sx={{ color: "primary.main" }}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,10 @@ export { BaseFieldset } from "./base-fieldset";
|
||||
export { BaseLoading } from "./base-loading";
|
||||
export { BaseLoadingOverlay } from "./base-loading-overlay";
|
||||
export { BasePage } from "./base-page";
|
||||
export {
|
||||
BaseSearchPanel,
|
||||
type BaseSearchPanelField,
|
||||
} from "./base-search-panel";
|
||||
export { BaseSearchBox, type SearchState } from "./base-search-box";
|
||||
export {
|
||||
BaseSplitChipEditor,
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import {
|
||||
DeleteForeverRounded,
|
||||
FilterListRounded,
|
||||
PauseCircleOutlineRounded,
|
||||
PlayCircleOutlineRounded,
|
||||
TableChartRounded,
|
||||
TableRowsRounded,
|
||||
} from "@mui/icons-material";
|
||||
import {
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Fab,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Zoom,
|
||||
} from "@mui/material";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { closeAllConnections, closeConnection } from "tauri-plugin-mihomo-api";
|
||||
@@ -30,8 +23,9 @@ import { closeAllConnections, closeConnection } from "tauri-plugin-mihomo-api";
|
||||
import {
|
||||
BaseEmpty,
|
||||
BasePage,
|
||||
BaseSearchBox,
|
||||
BaseSearchPanel,
|
||||
BaseStyledSelect,
|
||||
type BaseSearchPanelField,
|
||||
type SearchState,
|
||||
} from "@/components/base";
|
||||
import {
|
||||
@@ -111,6 +105,8 @@ type ConnectionFilters = {
|
||||
destinationPort: string[];
|
||||
};
|
||||
|
||||
type FilterField = keyof ConnectionFilters;
|
||||
|
||||
const EMPTY_FILTERS: ConnectionFilters = {
|
||||
host: [],
|
||||
sourceIP: [],
|
||||
@@ -120,6 +116,14 @@ const EMPTY_FILTERS: ConnectionFilters = {
|
||||
destinationPort: [],
|
||||
};
|
||||
|
||||
const normalizeFilterValue = (field: FilterField, value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return field === "host" || field === "network"
|
||||
? trimmed.toLowerCase()
|
||||
: trimmed;
|
||||
};
|
||||
|
||||
const getUniqueValues = (values: Array<string | undefined>) => {
|
||||
const set = new Set<string>();
|
||||
values.forEach((value) => {
|
||||
@@ -140,9 +144,10 @@ const ConnectionsPage = () => {
|
||||
"active",
|
||||
);
|
||||
const [filters, setFilters] = useState<ConnectionFilters>(EMPTY_FILTERS);
|
||||
const [filterAnchorEl, setFilterAnchorEl] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const [activeFilterField, setActiveFilterField] =
|
||||
useState<FilterField>("sourceIP");
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -196,6 +201,36 @@ const ConnectionsPage = () => {
|
||||
};
|
||||
}, [baseConnections]);
|
||||
|
||||
const filterFields = useMemo<BaseSearchPanelField<FilterField>[]>(
|
||||
() => [
|
||||
{
|
||||
key: "sourceIP" as const,
|
||||
label: t("connections.components.fields.sourceIP"),
|
||||
},
|
||||
{
|
||||
key: "destinationIP" as const,
|
||||
label: t("connections.components.fields.destinationIP"),
|
||||
},
|
||||
{
|
||||
key: "host" as const,
|
||||
label: t("connections.components.fields.host"),
|
||||
},
|
||||
{
|
||||
key: "network" as const,
|
||||
label: t("connections.components.fields.network"),
|
||||
},
|
||||
{
|
||||
key: "sourcePort" as const,
|
||||
label: t("connections.components.fields.sourcePort"),
|
||||
},
|
||||
{
|
||||
key: "destinationPort" as const,
|
||||
label: t("connections.components.fields.destinationPort"),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const normalizedFilters = useMemo(
|
||||
() => ({
|
||||
host: new Set(
|
||||
@@ -222,6 +257,35 @@ const ConnectionsPage = () => {
|
||||
[filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFilterQuery("");
|
||||
}, [activeFilterField]);
|
||||
|
||||
const activeFieldOptions = useMemo(() => {
|
||||
const options = filterOptions[activeFilterField] ?? [];
|
||||
const selected = filters[activeFilterField] ?? [];
|
||||
const map = new Map<string, string>();
|
||||
selected.forEach((value) => {
|
||||
const normalized = normalizeFilterValue(activeFilterField, value);
|
||||
if (!normalized) return;
|
||||
map.set(normalized, value.trim());
|
||||
});
|
||||
options.forEach((value) => {
|
||||
const normalized = normalizeFilterValue(activeFilterField, value);
|
||||
if (!normalized || map.has(normalized)) return;
|
||||
map.set(normalized, value);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [activeFilterField, filterOptions, filters]);
|
||||
|
||||
const visibleFieldOptions = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) return activeFieldOptions;
|
||||
return activeFieldOptions.filter((option) =>
|
||||
option.toLowerCase().includes(query),
|
||||
);
|
||||
}, [activeFieldOptions, filterQuery]);
|
||||
|
||||
const [filterConn] = useMemo(() => {
|
||||
const orderFunc = orderFunctionMap[curOrderOpt];
|
||||
let matchConns = baseConnections.filter((conn) => {
|
||||
@@ -322,6 +386,43 @@ const ConnectionsPage = () => {
|
||||
|
||||
const detailRef = useRef<ConnectionDetailRef>(null!);
|
||||
|
||||
const isValueSelected = useCallback(
|
||||
(field: FilterField, value: string) =>
|
||||
normalizedFilters[field].has(normalizeFilterValue(field, value)),
|
||||
[normalizedFilters],
|
||||
);
|
||||
|
||||
const toggleFilterValue = useCallback((field: FilterField, value: string) => {
|
||||
const normalized = normalizeFilterValue(field, value);
|
||||
if (!normalized) return;
|
||||
const trimmed = value.trim();
|
||||
setFilters((prev) => {
|
||||
const current = prev[field] ?? [];
|
||||
const next = current.filter(
|
||||
(item) => normalizeFilterValue(field, item) !== normalized,
|
||||
);
|
||||
if (next.length === current.length) {
|
||||
return { ...prev, [field]: [...current, trimmed] };
|
||||
}
|
||||
return { ...prev, [field]: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addFilterValue = useCallback((field: FilterField, value: string) => {
|
||||
const normalized = normalizeFilterValue(field, value);
|
||||
if (!normalized) return;
|
||||
const trimmed = value.trim();
|
||||
setFilters((prev) => {
|
||||
const current = prev[field] ?? [];
|
||||
if (
|
||||
current.some((item) => normalizeFilterValue(field, item) === normalized)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [field]: [...current, trimmed] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(matcher: (content: string) => boolean, state: SearchState) => {
|
||||
setMatch(() => matcher);
|
||||
@@ -332,16 +433,6 @@ const ConnectionsPage = () => {
|
||||
|
||||
const hasTableData = filterConn.length > 0;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: keyof ConnectionFilters) => (_: unknown, values: string[]) => {
|
||||
const nextValues = Array.from(
|
||||
new Set(values.map((value) => value.trim()).filter(Boolean)),
|
||||
);
|
||||
setFilters((prev) => ({ ...prev, [key]: nextValues }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters({ ...EMPTY_FILTERS });
|
||||
}, []);
|
||||
@@ -460,158 +551,39 @@ const ConnectionsPage = () => {
|
||||
))}
|
||||
</BaseStyledSelect>
|
||||
)}
|
||||
<Tooltip title={t("connections.components.actions.filter")}>
|
||||
<Badge
|
||||
color="primary"
|
||||
variant="dot"
|
||||
overlap="circular"
|
||||
invisible={!hasActiveFilters}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={(event) =>
|
||||
setFilterAnchorEl((prev) => (prev ? null : event.currentTarget))
|
||||
}
|
||||
aria-label={t("connections.components.actions.filter")}
|
||||
>
|
||||
<FilterListRounded />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={Boolean(filterAnchorEl)}
|
||||
anchorEl={filterAnchorEl}
|
||||
onClose={() => setFilterAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
>
|
||||
<Box sx={{ p: 2, width: 360 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.components.actions.filter")}
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleClearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
>
|
||||
{t("connections.components.actions.clearFilters")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Stack spacing={1.5}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
options={filterOptions.host}
|
||||
value={filters.host}
|
||||
onChange={handleFilterChange("host")}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("connections.components.fields.host")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
options={filterOptions.sourceIP}
|
||||
value={filters.sourceIP}
|
||||
onChange={handleFilterChange("sourceIP")}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("connections.components.fields.sourceIP")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
options={filterOptions.destinationIP}
|
||||
value={filters.destinationIP}
|
||||
onChange={handleFilterChange("destinationIP")}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("connections.components.fields.destinationIP")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
options={filterOptions.network}
|
||||
value={filters.network}
|
||||
onChange={handleFilterChange("network")}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("connections.components.fields.network")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
options={filterOptions.sourcePort}
|
||||
value={filters.sourcePort}
|
||||
onChange={handleFilterChange("sourcePort")}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("connections.components.fields.sourcePort")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
options={filterOptions.destinationPort}
|
||||
value={filters.destinationPort}
|
||||
onChange={handleFilterChange("destinationPort")}
|
||||
filterSelectedOptions
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("connections.components.fields.destinationPort")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Popover>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
"& > *": {
|
||||
flex: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BaseSearchBox onSearch={handleSearch} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<BaseSearchPanel
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
onSearch={handleSearch}
|
||||
filterLabel={t("connections.components.actions.filter")}
|
||||
showIndicator={hasActiveFilters}
|
||||
title={t("connections.components.actions.filter")}
|
||||
fields={filterFields.map((field) => ({
|
||||
...field,
|
||||
count: filters[field.key].length,
|
||||
}))}
|
||||
activeField={activeFilterField}
|
||||
onActiveFieldChange={setActiveFilterField}
|
||||
options={visibleFieldOptions}
|
||||
isOptionSelected={(option) =>
|
||||
isValueSelected(activeFilterField, option)
|
||||
}
|
||||
onToggleOption={(option) =>
|
||||
toggleFilterValue(activeFilterField, option)
|
||||
}
|
||||
searchValue={filterQuery}
|
||||
onSearchValueChange={setFilterQuery}
|
||||
onSearchSubmit={(value) => {
|
||||
addFilterValue(activeFilterField, value);
|
||||
setFilterQuery("");
|
||||
}}
|
||||
searchPlaceholder={t("shared.placeholders.filter")}
|
||||
emptyText={t("shared.statuses.empty")}
|
||||
clearLabel={t("connections.components.actions.clearFilters")}
|
||||
onClear={handleClearFilters}
|
||||
clearDisabled={!hasActiveFilters}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user