From e469c421ca082b709156e5d76c9db33691addbd5 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 23 Aug 2026 13:16:00 +0700 Subject: [PATCH] feat(web): enhance status toggle group and filter utilities - Introduced new properties for status toggle items, including `icon` and `tone`, to improve visual representation. - Updated the `StatusToggleGroup` component to utilize these new properties for better user feedback. - Enhanced filter utilities with additional functions for managing filter queries, including `getPrimaryTextField`, `getExtraFilterFields`, and `setFilterTextValue`. - Improved the `ResourcePageFiltered` component to integrate search functionality and manage filter states more effectively. This update aims to streamline the user experience in managing statuses and filters across the application. --- .../components/network/network-peers-grid.tsx | 2 +- .../network/network-speakers-grid.tsx | 2 +- .../src/components/reui-kit/filter-utils.ts | 99 +++++++++++++- .../src/components/reui-kit/resource-page.tsx | 122 +++++++++++++++--- .../src/components/status-toggle-group.tsx | 97 ++++++++++++-- apps/web/tsconfig.tsbuildinfo | 2 +- 6 files changed, 294 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/network/network-peers-grid.tsx b/apps/web/src/components/network/network-peers-grid.tsx index f467cf7..be95b0e 100644 --- a/apps/web/src/components/network/network-peers-grid.tsx +++ b/apps/web/src/components/network/network-peers-grid.tsx @@ -57,7 +57,7 @@ export const peerColumns: DataGridColumnDef[] = [ export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown { switch (field) { case 'name': - return item.name ?? item.neighbor + return `${item.name ?? ''} ${item.neighbor} ${item.remote_asn ?? ''}` case 'neighbor': return item.neighbor case 'session_state': diff --git a/apps/web/src/components/network/network-speakers-grid.tsx b/apps/web/src/components/network/network-speakers-grid.tsx index bdc2770..4d7caef 100644 --- a/apps/web/src/components/network/network-speakers-grid.tsx +++ b/apps/web/src/components/network/network-speakers-grid.tsx @@ -68,7 +68,7 @@ export const speakerColumns: DataGridColumnDef[] = [ export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown { switch (field) { case 'endpoint': - return `${item.endpoint} ${item.agent_domain ?? ''}` + return `${item.endpoint} ${item.agent_domain ?? ''} ${item.id}` case 'role': return item.role default: diff --git a/apps/web/src/components/reui-kit/filter-utils.ts b/apps/web/src/components/reui-kit/filter-utils.ts index 9ba7be6..47b0581 100644 --- a/apps/web/src/components/reui-kit/filter-utils.ts +++ b/apps/web/src/components/reui-kit/filter-utils.ts @@ -2,9 +2,17 @@ import { createFilterQuery, createFilterRule, flattenFilterConditions, + flattenFilterRules, + isFilterRule, + updateFilterRule, type FilterCondition, } from '@/components/reui/filters/filters-query' -import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types' +import type { + FilterField, + FilterGroupNode, + FilterNode, + FilterQuery, +} from '@/components/reui/filters/filters-types' /** Operators that take no value, so an empty `values` list is expected. */ const VALUELESS_OPERATORS = new Set(['empty', 'not_empty']) @@ -131,6 +139,95 @@ export function createSearchFilterField( } } +export function getPrimaryTextField(fields: FilterField[]): FilterField | undefined { + return fields.find((field) => field.type === 'text' && !field.fields) +} + +export function getExtraFilterFields( + fields: FilterField[], + primaryId?: string, +): FilterField[] { + if (!primaryId) return fields + return fields.filter((field) => field.id !== primaryId) +} + +function ruleFieldId(path: string[]): string | undefined { + return path[0] +} + +export function getFilterTextValue(query: FilterQuery, fieldId: string): string { + const rule = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId) + if (rule?.value == null) return '' + if (Array.isArray(rule.value)) return rule.value.map(String).join(' ') + return String(rule.value) +} + +export function setFilterTextValue( + query: FilterQuery, + fieldId: string, + text: string, +): FilterQuery { + const existing = flattenFilterRules(query).find((item) => ruleFieldId(item.path) === fieldId) + if (existing) { + return updateFilterRule(query, existing.id, { + operator: 'contains', + value: text, + }) + } + return { + ...query, + rules: [ + createFilterRule({ + id: `${fieldId}-1`, + path: [fieldId], + operator: 'contains', + value: text, + }), + ...query.rules, + ], + } +} + +function mapGroupRules( + group: FilterGroupNode, + map: (node: FilterNode) => FilterNode | null, +): FilterGroupNode { + const rules: FilterNode[] = [] + for (const child of group.rules) { + if (isFilterRule(child)) { + const next = map(child) + if (next) rules.push(next) + continue + } + const nested = mapGroupRules(child, map) + if (nested.rules.length > 0) rules.push(nested) + } + return { ...group, rules } +} + +export function stripFieldFromQuery(query: FilterQuery, fieldId: string): FilterQuery { + return mapGroupRules(query, (node) => { + if (isFilterRule(node) && ruleFieldId(node.path) === fieldId) return null + return node + }) +} + +export function mergeFieldRules( + extraQuery: FilterQuery, + sourceQuery: FilterQuery, + fieldId: string, +): FilterQuery { + const searchRules = flattenFilterRules(sourceQuery).filter( + (rule) => ruleFieldId(rule.path) === fieldId, + ) + const strippedExtra = stripFieldFromQuery(extraQuery, fieldId) + if (searchRules.length === 0) return strippedExtra + return { + ...strippedExtra, + rules: [...searchRules, ...strippedExtra.rules], + } +} + export function renderSelectedCount(values: unknown[]) { if (values.length === 0) return 'Выберите…' if (values.length > 1) return `${values.length} выбрано` diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index 601bdab..eb80d50 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -5,10 +5,14 @@ import { type RowSelectionState, type SortingState, } from '@tanstack/react-table' -import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react' import { EmptyState } from '@/components/empty-state' -import { StatusToggleGroup } from '@/components/status-toggle-group' +import { + StatusToggleGroup, + type StatusToggleTone, +} from '@/components/status-toggle-group' import { Badge } from '@/components/reui/badge' import { DataGrid, @@ -18,6 +22,7 @@ import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagina import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { Filters } from '@/components/reui/filters/filters' +import { flattenFilterConditions } from '@/components/reui/filters/filters-query' import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types' import { Frame, @@ -29,11 +34,27 @@ import { } from '@/components/reui/frame' import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { Button } from '@evobgp/ui/components/button' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from '@evobgp/ui/components/input-group' import { Separator } from '@evobgp/ui/components/separator' import { Skeleton } from '@evobgp/ui/components/skeleton' import { DATA_GRID_PAGINATION_RU } from '@/lib/data-grid-defaults' import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n' -import { applyFiltersToData, createEmptyFilterQuery } from './filter-utils' +import { + applyFiltersToData, + createEmptyFilterQuery, + createTextFilterQuery, + getActiveFilters, + getExtraFilterFields, + getFilterTextValue, + getPrimaryTextField, + mergeFieldRules, + setFilterTextValue, + stripFieldFromQuery, +} from './filter-utils' import { FrameDataGrid, applyKitActionColumn, @@ -48,6 +69,8 @@ export interface ResourcePageTab { id: string label: string count?: number + icon?: LucideIcon + tone?: StatusToggleTone } type SimpleGridPassthrough = Pick< @@ -286,11 +309,33 @@ function ResourcePageFiltered({ const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all') const activeTab = controlledTab ?? internalTab const showFilters = filterFields.length > 0 + const primaryTextField = useMemo(() => getPrimaryTextField(filterFields), [filterFields]) + const extraFilterFields = useMemo( + () => getExtraFilterFields(filterFields, primaryTextField?.id), + [filterFields, primaryTextField], + ) + const showSearch = Boolean(primaryTextField) + const showFiltersPopover = extraFilterFields.length > 0 const [internalQuery, setInternalQuery] = useState(createEmptyFilterQuery) const isQueryControlled = controlledQuery !== undefined const filterQuery = isQueryControlled ? controlledQuery : internalQuery const setFilterQuery = isQueryControlled ? onFilterQueryChange : setInternalQuery + const searchText = primaryTextField + ? getFilterTextValue(filterQuery, primaryTextField.id) + : '' + const extraQuery = useMemo( + () => + primaryTextField + ? stripFieldFromQuery(filterQuery, primaryTextField.id) + : filterQuery, + [filterQuery, primaryTextField], + ) + const extraActiveCount = useMemo( + () => getActiveFilters(flattenFilterConditions(extraQuery)).length, + [extraQuery], + ) + const hasDirtyFilters = searchText.trim() !== '' || extraActiveCount > 0 const [sorting, setSorting] = useState([]) const [rowSelection, setRowSelection] = useState({}) @@ -372,19 +417,38 @@ function ResourcePageFiltered({ [onTabChange, resetPagination], ) - const handleFiltersChange = useCallback( - (next: FilterQuery) => { - setFilterQuery(next) + const handleSearchChange = useCallback( + (value: string) => { + if (!primaryTextField) return + setFilterQuery(setFilterTextValue(filterQuery, primaryTextField.id, value)) resetPagination() }, - [setFilterQuery, resetPagination], + [filterQuery, primaryTextField, setFilterQuery, resetPagination], + ) + + const handleFiltersChange = useCallback( + (next: FilterQuery) => { + setFilterQuery( + primaryTextField + ? mergeFieldRules(next, filterQuery, primaryTextField.id) + : next, + ) + resetPagination() + }, + [filterQuery, primaryTextField, setFilterQuery, resetPagination], ) const handleClear = useCallback(() => { onClearFilters?.() - if (!isQueryControlled) setInternalQuery(createEmptyFilterQuery()) + if (!isQueryControlled) { + setInternalQuery( + primaryTextField + ? createTextFilterQuery(primaryTextField.id) + : createEmptyFilterQuery(), + ) + } resetPagination() - }, [onClearFilters, isQueryControlled, resetPagination]) + }, [onClearFilters, isQueryControlled, primaryTextField, resetPagination]) const countedTabs = useMemo( () => @@ -392,6 +456,8 @@ function ResourcePageFiltered({ id: tab.id, label: tab.label, count: tabCounts[tab.id] ?? tab.count ?? 0, + icon: tab.icon, + tone: tab.tone, })), [tabs, tabCounts], ) @@ -466,12 +532,27 @@ function ResourcePageFiltered({
-
- {showFilters ? ( +
+ {showSearch && primaryTextField ? ( + + + + handleSearchChange(event.target.value)} + placeholder={primaryTextField.placeholder ?? 'Поиск…'} + aria-label={primaryTextField.label || 'Поиск'} + /> + + ) : null} + {showFiltersPopover ? ( ({ } /> ) : null} + {!showSearch && !showFiltersPopover && countedTabs.length === 0 ? ( +
+ ) : null} +
+
{countedTabs.length > 0 ? ( ({ aria-label="Фильтр по статусу" /> ) : null} - {!showFilters && countedTabs.length === 0 ?
: null} -
-
{toolbarExtra} {selectedCount > 0 ? ( {selectedCount} выбрано ) : null} - {showFilters ? ( + {hasDirtyFilters ? (