From 0d418e2e3926d02f3c2c35f86bd348fc64c39266 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 20 Sep 2026 00:47:33 +0700 Subject: [PATCH] =?UTF-8?q?chore(ui):=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E=20Filters=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D0=B9=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/data-page-toolbar.tsx | 29 +- components/reui/filters.tsx | 1934 ------------------- docs/ux-ui-progress.md | 25 +- lib/data-filters/apply-filter-query.test.ts | 19 +- lib/data-filters/apply-reui-filters.ts | 40 - lib/data-filters/migrate-filter-query.ts | 15 - 6 files changed, 37 insertions(+), 2025 deletions(-) delete mode 100644 components/reui/filters.tsx delete mode 100644 lib/data-filters/apply-reui-filters.ts delete mode 100644 lib/data-filters/migrate-filter-query.ts diff --git a/components/data-page-toolbar.tsx b/components/data-page-toolbar.tsx index 0b589e13..733a381a 100644 --- a/components/data-page-toolbar.tsx +++ b/components/data-page-toolbar.tsx @@ -1,19 +1,13 @@ "use client" import { ReactNode } from "react" -import { ListFilterIcon, SearchIcon } from "lucide-react" +import { SearchIcon } from "lucide-react" import { cn } from "@/lib/utils" import { InputGroup, InputGroupAddon, InputGroupInput, } from "@/components/ui/input-group" -import { Button } from "@/components/ui/button" -import { - Filters, - type Filter, - type FilterFieldConfig, -} from "@/components/reui/filters" import { SegmentedControl } from "@/components/form-kit" import { DATA_PAGE_TOOLBAR_CLASS } from "@/components/data-grids/shared/data-grid-layout" @@ -41,9 +35,6 @@ interface DataPageToolbarProps { onChange: (value: T) => void options: { value: T; label: string; count?: number }[] } - filters?: Filter[] - onFiltersChange?: (filters: Filter[]) => void - filterFields?: FilterFieldConfig[] filterControl?: ReactNode countLabel?: string actions?: ReactNode @@ -56,9 +47,6 @@ function DataPageToolbar({ onSearchChange, searchPlaceholder = "Поиск…", segmented, - filters, - onFiltersChange, - filterFields, filterControl, countLabel, actions, @@ -74,20 +62,7 @@ function DataPageToolbar({ options={segmented.options} /> )} - {filterControl ?? (filterFields && filters && onFiltersChange && ( - - - Фильтры - - } - /> - ))} + {filterControl} {onSearchChange != null && ( diff --git a/components/reui/filters.tsx b/components/reui/filters.tsx deleted file mode 100644 index 862e2355..00000000 --- a/components/reui/filters.tsx +++ /dev/null @@ -1,1934 +0,0 @@ -"use client" - -import type React from "react" -import { - createContext, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react" -import { useRender } from "@base-ui/react/use-render" -import { cva } from "class-variance-authority" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - ButtonGroup, - ButtonGroupText, -} from "@/components/ui/button-group" -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, - InputGroupText, -} from "@/components/ui/input-group" -import { Kbd } from "@/components/ui/kbd" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { AlertCircleIcon, XIcon, CheckIcon } from "lucide-react" - -// i18n Configuration Interface -export interface FilterI18nConfig { - // UI Labels - addFilter: string - searchFields: string - noFieldsFound: string - noResultsFound: string - select: string - true: string - false: string - min: string - max: string - to: string - typeAndPressEnter: string - selected: string - selectedCount: string - percent: string - defaultCurrency: string - defaultColor: string - addFilterTitle: string - - // Operators - operators: { - is: string - isNot: string - isAnyOf: string - isNotAnyOf: string - includesAll: string - excludesAll: string - before: string - after: string - between: string - notBetween: string - contains: string - notContains: string - startsWith: string - endsWith: string - isExactly: string - equals: string - notEquals: string - greaterThan: string - lessThan: string - overlaps: string - includes: string - excludes: string - includesAllOf: string - includesAnyOf: string - empty: string - notEmpty: string - } - - // Placeholders - placeholders: { - enterField: (fieldType: string) => string - selectField: string - searchField: (fieldName: string) => string - enterKey: string - enterValue: string - } - - // Helper functions - helpers: { - formatOperator: (operator: string) => string - } - - // Validation - validation: { - invalidEmail: string - invalidUrl: string - invalidTel: string - invalid: string - } -} - -// Default English i18n configuration -export const DEFAULT_I18N: FilterI18nConfig = { - // UI Labels - addFilter: "Filter", - searchFields: "Filter...", - noFieldsFound: "No filters found.", - noResultsFound: "No results found.", - select: "Select...", - true: "True", - false: "False", - min: "Min", - max: "Max", - to: "to", - typeAndPressEnter: "Type and press Enter to add tag", - selected: "selected", - selectedCount: "selected", - percent: "%", - defaultCurrency: "$", - defaultColor: "#000000", - addFilterTitle: "Add filter", - - // Operators - operators: { - is: "is", - isNot: "is not", - isAnyOf: "is any of", - isNotAnyOf: "is not any of", - includesAll: "includes all", - excludesAll: "excludes all", - before: "before", - after: "after", - between: "between", - notBetween: "not between", - contains: "contains", - notContains: "does not contain", - startsWith: "starts with", - endsWith: "ends with", - isExactly: "is exactly", - equals: "equals", - notEquals: "not equals", - greaterThan: "greater than", - lessThan: "less than", - overlaps: "overlaps", - includes: "includes", - excludes: "excludes", - includesAllOf: "includes all of", - includesAnyOf: "includes any of", - empty: "is empty", - notEmpty: "is not empty", - }, - - // Placeholders - placeholders: { - enterField: (fieldType: string) => `Enter ${fieldType}...`, - selectField: "Select...", - searchField: (fieldName: string) => `Search ${fieldName.toLowerCase()}...`, - enterKey: "Enter key...", - enterValue: "Enter value...", - }, - - // Helper functions - helpers: { - formatOperator: (operator: string) => operator.replace(/_/g, " "), - }, - - // Validation - validation: { - invalidEmail: "Invalid email format", - invalidUrl: "Invalid URL format", - invalidTel: "Invalid phone format", - invalid: "Invalid input format", - }, -} - -// Context for all Filter component props -interface FilterContextValue { - variant: "solid" | "default" - size: "sm" | "default" | "lg" - radius: "default" | "full" - i18n: FilterI18nConfig - className?: string - showSearchInput?: boolean - trigger?: React.ReactNode - allowMultiple?: boolean -} - -const FilterContext = createContext({ - variant: "default", - size: "default", - radius: "default", - i18n: DEFAULT_I18N, - className: undefined, - showSearchInput: true, - trigger: undefined, - allowMultiple: true, -}) - -const useFilterContext = () => useContext(FilterContext) - -// Container variant for filters wrapper -const filtersContainerVariants = cva("flex flex-wrap items-center", { - variants: { - variant: { - solid: "gap-2", - default: "", - }, - size: { - sm: "gap-1.5", - default: "gap-2.5", - lg: "gap-3.5", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, -}) - -function FilterInput({ - field, - onBlur, - onKeyDown, - className, - ...props -}: React.InputHTMLAttributes & { - className?: string - field?: FilterFieldConfig -}) { - const context = useFilterContext() - const [isValid, setIsValid] = useState(true) - const [validationMessage, setValidationMessage] = useState("") - const inputRef = useRef(null) - - useEffect(() => { - if (props.autoFocus) { - const timer = setTimeout(() => { - inputRef.current?.focus() - }, 300) - return () => clearTimeout(timer) - } - }, [props.autoFocus]) - - // Validation function to check if input matches pattern - const validateInput = (value: string, pattern?: string): boolean => { - if (!pattern || !value) return true - const regex = new RegExp(pattern) - return regex.test(value) - } - - // Get validation message for field type - const getValidationMessage = (): string => { - return context.i18n.validation.invalid - } - - // Handle blur event - validate when user leaves input - const handleBlur = (e: React.FocusEvent) => { - const value = e.target.value - const pattern = field?.pattern || props.pattern - - // Only validate if there's a value and (pattern or validation function) - if (value && (pattern || field?.validation)) { - let valid = true - let customMessage = "" - - // If there's a custom validation function, use it - if (field?.validation) { - const result = field.validation(value) - // Handle both boolean and object return types - if (typeof result === "boolean") { - valid = result - } else { - valid = result.valid - customMessage = result.message || "" - } - } else if (pattern) { - // Use pattern validation - valid = validateInput(value, pattern) - } - - setIsValid(valid) - setValidationMessage(valid ? "" : customMessage || getValidationMessage()) - } else { - // Reset validation state for empty values or no validation - setIsValid(true) - setValidationMessage("") - } - - // Call the original onBlur if provided - onBlur?.(e) - } - - // Handle keydown event - hide validation error when user starts typing - const handleKeyDown = (e: React.KeyboardEvent) => { - // Hide validation error when user starts typing (any key except special keys) - if ( - !isValid && - ![ - "Tab", - "Escape", - "Enter", - "ArrowUp", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - ].includes(e.key) - ) { - setIsValid(true) - setValidationMessage("") - } - - // Call the original onKeyDown if provided - onKeyDown?.(e) - } - - return ( - - {field?.prefix && ( - - {field.prefix} - - )} - - {!isValid && validationMessage && ( - - - }> - - - -

{validationMessage}

-
-
-
- )} - - {field?.suffix && ( - - {field.suffix} - - )} -
- ) -} - -interface FilterRemoveButtonProps extends React.ButtonHTMLAttributes { - icon?: React.ReactNode -} - -function FilterRemoveButton({ - className, - icon = ( - - ), - ...props -}: FilterRemoveButtonProps) { - const context = useFilterContext() - - const sizeMap = { - sm: "sm" as const, - default: "sm" as const, - lg: "default" as const, - } - - return ( - - ) -} - -// Generic types for flexible filter system -export interface FilterOption { - value: T - label: string - icon?: React.ReactNode - metadata?: Record - className?: string -} - -export interface FilterOperator { - value: string - label: string - supportsMultiple?: boolean -} - -// Custom renderer props interface -export interface CustomRendererProps { - field: FilterFieldConfig - values: T[] - onChange: (values: T[]) => void - operator: string -} - -// Grouped field configuration interface -export interface FilterFieldGroup { - group?: string - fields: FilterFieldConfig[] -} - -// Union type for both flat and grouped field configurations -export type FilterFieldsConfig = - | FilterFieldConfig[] - | FilterFieldGroup[] - -export interface FilterFieldConfig { - key?: string - label?: string - icon?: React.ReactNode - type?: "select" | "multiselect" | "text" | "custom" | "separator" - // Group-level configuration - group?: string - fields?: FilterFieldConfig[] - // Field-specific options - options?: FilterOption[] - operators?: FilterOperator[] - customRenderer?: (props: CustomRendererProps) => React.ReactNode - customValueRenderer?: ( - values: T[], - options: FilterOption[] - ) => React.ReactNode - placeholder?: string - searchable?: boolean - maxSelections?: number - min?: number - max?: number - step?: number - prefix?: string | React.ReactNode - suffix?: string | React.ReactNode - pattern?: string - validation?: ( - value: unknown - ) => boolean | { valid: boolean; message?: string } - allowCustomValues?: boolean - className?: string - menuPopupClassName?: string - // Grouping options (legacy support) - groupLabel?: string - // Boolean field options - onLabel?: string - offLabel?: string - // Input event handlers - onInputChange?: (e: React.ChangeEvent) => void - // Default operator to use when creating a filter for this field - defaultOperator?: string - // Controlled values support for this field - value?: T[] - onValueChange?: (values: T[]) => void -} - -// Helper functions to handle both flat and grouped field configurations -const isFieldGroup = ( - item: FilterFieldConfig | FilterFieldGroup -): item is FilterFieldGroup => { - return "fields" in item && Array.isArray(item.fields) -} - -// Helper function to check if a FilterFieldConfig is a group-level configuration -const isGroupLevelField = ( - field: FilterFieldConfig -): boolean => { - return Boolean(field.group && field.fields) -} - -const flattenFields = ( - fields: FilterFieldsConfig -): FilterFieldConfig[] => { - return fields.reduce[]>((acc, item) => { - if (isFieldGroup(item)) { - return [...acc, ...item.fields] - } - // Handle group-level fields (new structure) - if (isGroupLevelField(item)) { - return [...acc, ...item.fields!] - } - return [...acc, item] - }, []) -} - -const getFieldsMap = ( - fields: FilterFieldsConfig -): Record> => { - const flatFields = flattenFields(fields) - return flatFields.reduce( - (acc, field) => { - // Only add fields that have a key (skip group-level configurations) - if (field.key) { - acc[field.key] = field - } - return acc - }, - {} as Record> - ) -} - -// Helper function to create operators from i18n config -const createOperatorsFromI18n = ( - i18n: FilterI18nConfig -): Record => ({ - select: [ - { value: "is", label: i18n.operators.is }, - { value: "is_not", label: i18n.operators.isNot }, - { value: "empty", label: i18n.operators.empty }, - { value: "not_empty", label: i18n.operators.notEmpty }, - ], - multiselect: [ - { value: "is_any_of", label: i18n.operators.isAnyOf }, - { value: "is_not_any_of", label: i18n.operators.isNotAnyOf }, - { value: "includes_all", label: i18n.operators.includesAll }, - { value: "excludes_all", label: i18n.operators.excludesAll }, - { value: "empty", label: i18n.operators.empty }, - { value: "not_empty", label: i18n.operators.notEmpty }, - ], - text: [ - { value: "contains", label: i18n.operators.contains }, - { value: "not_contains", label: i18n.operators.notContains }, - { value: "starts_with", label: i18n.operators.startsWith }, - { value: "ends_with", label: i18n.operators.endsWith }, - { value: "is", label: i18n.operators.isExactly }, - { value: "empty", label: i18n.operators.empty }, - { value: "not_empty", label: i18n.operators.notEmpty }, - ], - custom: [ - { value: "is", label: i18n.operators.is }, - { value: "after", label: i18n.operators.after }, - { value: "is", label: i18n.operators.is }, - { value: "between", label: i18n.operators.between }, - { value: "empty", label: i18n.operators.empty }, - { value: "not_empty", label: i18n.operators.notEmpty }, - ], -}) - -// Default operators for different field types (using default i18n) -export const DEFAULT_OPERATORS: Record = - createOperatorsFromI18n(DEFAULT_I18N) - -// Helper function to get operators for a field -const getOperatorsForField = ( - field: FilterFieldConfig, - values: T[], - i18n: FilterI18nConfig -): FilterOperator[] => { - if (field.operators) return field.operators - - const operators = createOperatorsFromI18n(i18n) - - // Determine field type for operator selection - let fieldType = field.type || "select" - - // If it's a select field but has multiple values, treat as multiselect - if (fieldType === "select" && values.length > 1) { - fieldType = "multiselect" - } - - // If it's a multiselect field or has multiselect operators, use multiselect operators - if (fieldType === "multiselect" || field.type === "multiselect") { - return operators.multiselect - } - - return operators[fieldType] || operators.select -} - -interface FilterOperatorDropdownProps { - field: FilterFieldConfig - operator: string - values: T[] - onChange: (operator: string) => void -} - -function FilterOperatorDropdown({ - field, - operator, - values, - onChange, -}: FilterOperatorDropdownProps) { - const context = useFilterContext() - const operators = getOperatorsForField(field, values, context.i18n) - - // Find the operator label, with fallback to formatted operator name - const operatorLabel = - operators.find((op) => op.value === operator)?.label || - context.i18n.helpers.formatOperator(operator) - - return ( - - - {operatorLabel} - - } - /> - - {operators.map((op) => ( - onChange(op.value)} - className={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground flex items-center justify-between" - )} - > - {op.label} - - - ))} - - - ) -} - -interface FilterValueSelectorProps { - field: FilterFieldConfig - values: T[] - onChange: (values: T[]) => void - operator: string - autoFocus?: boolean -} - -interface SelectOptionsPopoverProps { - field: FilterFieldConfig - values: T[] - onChange: (values: T[]) => void - onClose?: () => void - inline?: boolean -} - -function SelectOptionsPopover({ - field, - values, - onChange, - onClose, - inline = false, -}: SelectOptionsPopoverProps) { - const [open, setOpen] = useState(false) - const [searchInput, setSearchInput] = useState("") - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const inputRef = useRef(null) - const context = useFilterContext() - const baseId = useId() - - useEffect(() => { - if (open) { - inputRef.current?.focus() - } - }, [open]) - - useEffect(() => { - setHighlightedIndex(-1) - }, [searchInput, open]) - - useEffect(() => { - if (highlightedIndex >= 0 && open) { - const element = document.getElementById( - `${baseId}-item-${highlightedIndex}` - ) - element?.scrollIntoView({ block: "nearest" }) - } - }, [highlightedIndex, open, baseId]) - - const isMultiSelect = field.type === "multiselect" || values.length > 1 - const effectiveValues = - (field.value !== undefined ? (field.value as T[]) : values) || [] - - const selectedOptions = - field.options?.filter((opt) => effectiveValues.includes(opt.value)) || [] - const unselectedOptions = - field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || [] - - // Filter options based on search input - const filteredSelectedOptions = selectedOptions // Keep all selected visible - const filteredUnselectedOptions = unselectedOptions.filter((opt) => - opt.label.toLowerCase().includes(searchInput.toLowerCase()) - ) - - const allFilteredOptions = useMemo( - () => [...filteredSelectedOptions, ...filteredUnselectedOptions], - [filteredSelectedOptions, filteredUnselectedOptions] - ) - - const handleClose = () => { - setOpen(false) - onClose?.() - } - - const renderMenuContent = () => ( - <> - {field.searchable !== false && ( - <> - = 0 - ? `${baseId}-item-${highlightedIndex}` - : undefined - } - placeholder={context.i18n.placeholders.searchField( - field.label || "" - )} - className={cn( - "border-input h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", - "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", - open && "placeholder:text-foreground" - )} - value={searchInput} - onChange={(e) => setSearchInput(e.target.value)} - onBlur={() => open && inputRef.current?.focus()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "ArrowDown") { - e.preventDefault() - if (allFilteredOptions.length > 0) { - setHighlightedIndex((prev) => - prev < allFilteredOptions.length - 1 ? prev + 1 : 0 - ) - } - } else if (e.key === "ArrowUp") { - e.preventDefault() - if (allFilteredOptions.length > 0) { - setHighlightedIndex((prev) => - prev > 0 ? prev - 1 : allFilteredOptions.length - 1 - ) - } - } else if (e.key === "ArrowLeft") { - e.preventDefault() - setOpen(false) - } else if (e.key === "Enter" && highlightedIndex >= 0) { - e.preventDefault() - const option = allFilteredOptions[highlightedIndex] - if (option) { - const isSelected = effectiveValues.includes(option.value as T) - const next = isSelected - ? (effectiveValues.filter((v) => v !== option.value) as T[]) - : isMultiSelect - ? ([...effectiveValues, option.value] as T[]) - : ([option.value] as T[]) - - if ( - !isSelected && - isMultiSelect && - field.maxSelections && - next.length > field.maxSelections - ) { - return - } - - if (field.onValueChange) { - field.onValueChange(next) - } else { - onChange(next) - } - if (!isMultiSelect) handleClose() - } - } - e.stopPropagation() - }} - /> - - - )} -
-
- - {allFilteredOptions.length === 0 && ( -
- {context.i18n.noResultsFound} -
- )} - - {/* Selected items */} - {filteredSelectedOptions.length > 0 && ( - - {filteredSelectedOptions.map((option, index) => { - const isHighlighted = highlightedIndex === index - const itemId = `${baseId}-item-${index}` - - return ( - setHighlightedIndex(index)} - checked={true} - className={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground", - option.className - )} - onSelect={(e) => { - if (isMultiSelect) e.preventDefault() - }} - onCheckedChange={() => { - const next = effectiveValues.filter( - (v) => v !== option.value - ) as T[] - if (field.onValueChange) { - field.onValueChange(next) - } else { - onChange(next) - } - if (!isMultiSelect) handleClose() - }} - > - {option.icon && option.icon} - {option.label} - - ) - })} - - )} - - {/* Separator */} - {filteredSelectedOptions.length > 0 && - filteredUnselectedOptions.length > 0 && ( - - )} - - {/* Available items */} - {filteredUnselectedOptions.length > 0 && ( - - {filteredUnselectedOptions.map((option, index) => { - const overallIndex = index + filteredSelectedOptions.length - const isHighlighted = highlightedIndex === overallIndex - const itemId = `${baseId}-item-${overallIndex}` - - return ( - setHighlightedIndex(overallIndex)} - checked={false} - className={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground", - option.className - )} - onSelect={(e) => { - if (isMultiSelect) e.preventDefault() - }} - onCheckedChange={() => { - const next = isMultiSelect - ? ([...effectiveValues, option.value] as T[]) - : ([option.value] as T[]) - - if ( - isMultiSelect && - field.maxSelections && - next.length > field.maxSelections - ) { - return - } - - if (field.onValueChange) { - field.onValueChange(next) - } else { - onChange(next) - } - if (!isMultiSelect) handleClose() - }} - > - {option.icon && option.icon} - {option.label} - - ) - })} - - )} -
-
-
- - ) - - if (inline) { - return
{renderMenuContent()}
- } - - return ( - { - setOpen(open) - if (!open) { - setTimeout(() => setSearchInput(""), 200) - } - }} - > - -
- {field.customValueRenderer ? ( - field.customValueRenderer(values, field.options || []) - ) : ( - <> - {selectedOptions.length > 0 && ( -
- {selectedOptions.slice(0, 3).map((option) => ( -
{option.icon}
- ))} -
- )} - {selectedOptions.length === 1 - ? selectedOptions[0].label - : selectedOptions.length > 1 - ? `${selectedOptions.length} ${context.i18n.selectedCount}` - : context.i18n.select} - - )} -
- - } - /> - - {renderMenuContent()} - -
- ) -} - -function FilterValueSelector({ - field, - values, - onChange, - operator, - autoFocus, -}: FilterValueSelectorProps) { - const context = useFilterContext() - - if (operator === "empty" || operator === "not_empty") { - return null - } - - if (field.customRenderer) { - return ( - - {field.customRenderer({ field, values, onChange, operator })} - - ) - } - - if (field.type === "text") { - return ( - onChange([e.target.value] as T[])} - placeholder={field.placeholder} - pattern={field.pattern} - field={field} - className={cn("w-36", field.className)} - autoFocus={autoFocus} - /> - ) - } - - if (field.type === "select" || field.type === "multiselect") { - return ( - - ) - } - - return ( - - ) -} -export interface Filter { - id: string - field: string - operator: string - values: T[] -} - -export interface FilterGroup { - id: string - label?: string - filters: Filter[] - fields: FilterFieldConfig[] -} - -interface FiltersContentProps { - filters: Filter[] - fields: FilterFieldsConfig - onChange: (filters: Filter[]) => void -} - -export const FiltersContent = ({ - filters, - fields, - onChange, -}: FiltersContentProps) => { - const context = useFilterContext() - const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) - - const updateFilter = useCallback( - (filterId: string, updates: Partial>) => { - onChange( - filters.map((filter) => { - if (filter.id === filterId) { - const updatedFilter = { ...filter, ...updates } - if ( - updates.operator === "empty" || - updates.operator === "not_empty" - ) { - updatedFilter.values = [] as T[] - } - return updatedFilter - } - return filter - }) - ) - }, - [filters, onChange] - ) - - const removeFilter = useCallback( - (filterId: string) => { - onChange(filters.filter((filter) => filter.id !== filterId)) - }, - [filters, onChange] - ) - - return ( -
- {filters.map((filter) => { - const field = fieldsMap[filter.field] - if (!field) return null - - return ( - - - {field.icon && field.icon} - {field.label} - - - - field={field} - operator={filter.operator} - values={filter.values} - onChange={(operator) => updateFilter(filter.id, { operator })} - /> - - - field={field} - values={filter.values} - onChange={(values) => updateFilter(filter.id, { values })} - operator={filter.operator} - autoFocus={false} - /> - - removeFilter(filter.id)} /> - - ) - })} -
- ) -} - -interface FiltersProps { - filters: Filter[] - fields: FilterFieldsConfig - onChange: (filters: Filter[]) => void - className?: string - variant?: "solid" | "default" - size?: "sm" | "default" | "lg" - radius?: "default" | "full" - i18n?: Partial - showSearchInput?: boolean - trigger?: React.ReactNode - allowMultiple?: boolean - menuPopupClassName?: string - collapseAddButton?: boolean - enableShortcut?: boolean - shortcutKey?: string - shortcutLabel?: string -} - -interface FilterSubmenuContentProps { - field: FilterFieldConfig - currentValues: T[] - isMultiSelect: boolean - onToggle: (value: T, isSelected: boolean) => void - i18n: FilterI18nConfig - isActive?: boolean - onActive?: () => void - onBack?: () => void - onClose?: () => void -} - -function FilterSubmenuContent({ - field, - currentValues, - isMultiSelect, - onToggle, - i18n, - isActive, - onActive, - onBack, - onClose, -}: FilterSubmenuContentProps) { - const [searchInput, setSearchInput] = useState("") - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const inputRef = useRef(null) - const baseId = useId() - - useEffect(() => { - if (isActive) { - if (field.searchable !== false) { - inputRef.current?.focus() - } else { - const listbox = document.getElementById(`${baseId}-listbox`) - listbox?.focus() - } - } - }, [isActive, field.searchable, baseId]) - - useEffect(() => { - setHighlightedIndex(-1) - }, [searchInput]) - - useEffect(() => { - if (highlightedIndex >= 0 && isActive) { - const element = document.getElementById( - `${baseId}-item-${highlightedIndex}` - ) - element?.scrollIntoView({ block: "nearest" }) - } - }, [highlightedIndex, isActive, baseId]) - - const filteredOptions = useMemo(() => { - return ( - field.options?.filter((option) => { - const isSelected = currentValues.includes(option.value) - if (isSelected) return true - if (!searchInput) return true - return option.label.toLowerCase().includes(searchInput.toLowerCase()) - }) || [] - ) - }, [field.options, searchInput, currentValues]) - - useEffect(() => { - if (isActive && filteredOptions.length > 0) { - setHighlightedIndex(0) - } - }, [isActive, filteredOptions.length]) - - return ( -
- {field.searchable !== false && ( - <> - = 0 - ? `${baseId}-item-${highlightedIndex}` - : undefined - } - placeholder={i18n.placeholders.searchField(field.label || "")} - className={cn( - "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", - "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", - isActive && "placeholder:text-foreground" - )} - value={searchInput} - onBlur={() => isActive && inputRef.current?.focus()} - onChange={(e) => setSearchInput(e.target.value)} - onFocus={() => onActive?.()} - onMouseEnter={(e) => { - onActive?.() - e.stopPropagation() - }} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "ArrowDown") { - e.preventDefault() - if (filteredOptions.length > 0) { - setHighlightedIndex((prev) => - prev < filteredOptions.length - 1 ? prev + 1 : 0 - ) - } - } else if (e.key === "ArrowUp") { - e.preventDefault() - if (filteredOptions.length > 0) { - setHighlightedIndex((prev) => - prev > 0 ? prev - 1 : filteredOptions.length - 1 - ) - } - } else if (e.key === "ArrowLeft") { - e.preventDefault() - onBack?.() - } else if (e.key === "Enter" && highlightedIndex >= 0) { - e.preventDefault() - const option = filteredOptions[highlightedIndex] - if (option) { - onToggle( - option.value as T, - currentValues.includes(option.value) - ) - if (!isMultiSelect) { - onBack?.() - } - } - } else if (e.key === "Escape") { - e.preventDefault() - onClose?.() - } - e.stopPropagation() - }} - /> - - - )} -
-
{ - if (field.searchable === false) { - if (e.key === "ArrowDown") { - e.preventDefault() - if (filteredOptions.length > 0) { - setHighlightedIndex((prev) => - prev < filteredOptions.length - 1 ? prev + 1 : 0 - ) - } - } else if (e.key === "ArrowUp") { - e.preventDefault() - if (filteredOptions.length > 0) { - setHighlightedIndex((prev) => - prev > 0 ? prev - 1 : filteredOptions.length - 1 - ) - } - } else if (e.key === "ArrowLeft") { - e.preventDefault() - onBack?.() - } else if (e.key === "Enter" && highlightedIndex >= 0) { - e.preventDefault() - const option = filteredOptions[highlightedIndex] - if (option) { - onToggle( - option.value as T, - currentValues.includes(option.value) - ) - if (!isMultiSelect) { - onBack?.() - } - } - } else if (e.key === "Escape") { - e.preventDefault() - onClose?.() - } - e.stopPropagation() - } - }} - > - - {filteredOptions.length === 0 ? ( -
- {i18n.noResultsFound} -
- ) : ( - - {filteredOptions.map((option, index) => { - const isSelected = currentValues.includes(option.value) - const isHighlighted = highlightedIndex === index - const itemId = `${baseId}-item-${index}` - - return ( - setHighlightedIndex(index)} - checked={isSelected} - className={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground", - option.className - )} - onSelect={(e) => { - if (isMultiSelect) e.preventDefault() - }} - onCheckedChange={() => - onToggle(option.value as T, isSelected) - } - > - {option.icon && option.icon} - {option.label} - - ) - })} - - )} -
-
-
-
- ) -} - -export function Filters({ - filters, - fields, - onChange, - className, - variant = "default", - size = "default", - radius = "default", - i18n, - showSearchInput = true, - trigger, - allowMultiple = true, - menuPopupClassName, - enableShortcut = false, - shortcutKey = "f", - shortcutLabel = "F", -}: FiltersProps) { - const [addFilterOpen, setAddFilterOpen] = useState(false) - const [menuSearchInput, setMenuSearchInput] = useState("") - const [activeMenu, setActiveMenu] = useState("root") - const [openSubMenu, setOpenSubMenu] = useState(null) - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const [lastAddedFilterId, setLastAddedFilterId] = useState( - null - ) - const rootInputRef = useRef(null) - const rootId = useId() - - useEffect(() => { - if (!enableShortcut) return - - const handleKeyDown = (e: KeyboardEvent) => { - if ( - e.key.toLowerCase() === shortcutKey.toLowerCase() && - !addFilterOpen && - !( - document.activeElement instanceof HTMLInputElement || - document.activeElement instanceof HTMLTextAreaElement - ) - ) { - e.preventDefault() - setAddFilterOpen(true) - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [enableShortcut, shortcutKey, addFilterOpen]) - - useEffect(() => { - if (addFilterOpen && activeMenu === "root") { - rootInputRef.current?.focus() - } - }, [addFilterOpen, activeMenu]) - - useEffect(() => { - setHighlightedIndex(-1) - }, [menuSearchInput]) - - useEffect(() => { - if (highlightedIndex >= 0 && addFilterOpen) { - const element = document.getElementById( - `${rootId}-item-${highlightedIndex}` - ) - element?.scrollIntoView({ block: "nearest" }) - } - }, [highlightedIndex, addFilterOpen, rootId]) - - useEffect(() => { - if (!addFilterOpen) { - setOpenSubMenu(null) - } - }, [addFilterOpen]) - - // Track which filter instance is being built in the current Add Filter menu session - // Maps fieldKey -> unique filterId created during this open session - const [sessionFilterIds, setSessionFilterIds] = useState< - Record - >({}) - - useEffect(() => { - if (lastAddedFilterId) { - const timer = setTimeout(() => { - setLastAddedFilterId(null) - }, 1000) - return () => clearTimeout(timer) - } - }, [lastAddedFilterId]) - - const mergedI18n: FilterI18nConfig = { - ...DEFAULT_I18N, - ...i18n, - operators: { ...DEFAULT_I18N.operators, ...i18n?.operators }, - placeholders: { ...DEFAULT_I18N.placeholders, ...i18n?.placeholders }, - validation: { ...DEFAULT_I18N.validation, ...i18n?.validation }, - } - - const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]) - - const updateFilter = useCallback( - (filterId: string, updates: Partial>) => { - onChange( - filters.map((filter) => { - if (filter.id === filterId) { - const updatedFilter = { ...filter, ...updates } - if ( - updates.operator === "empty" || - updates.operator === "not_empty" - ) { - updatedFilter.values = [] as T[] - } - return updatedFilter - } - return filter - }) - ) - }, - [filters, onChange] - ) - - const removeFilter = useCallback( - (filterId: string) => { - onChange(filters.filter((filter) => filter.id !== filterId)) - }, - [filters, onChange] - ) - - const addFilter = useCallback( - (fieldKey: string) => { - const field = fieldsMap[fieldKey] - if (field && field.key) { - const defaultOperator = - field.defaultOperator || - (field.type === "multiselect" ? "is_any_of" : "is") - const defaultValues: unknown[] = field.type === "text" ? [""] : [] - const newFilter = createFilter( - fieldKey, - defaultOperator, - defaultValues as T[] - ) - setLastAddedFilterId(newFilter.id) - onChange([...filters, newFilter]) - setAddFilterOpen(false) - setMenuSearchInput("") - } - }, - [fieldsMap, filters, onChange] - ) - - useEffect(() => { - if (addFilterOpen && activeMenu === "root") { - rootInputRef.current?.focus() - } - }, [addFilterOpen, activeMenu]) - - const selectableFields = useMemo(() => { - const flatFields = flattenFields(fields) - return flatFields.filter((field) => { - if (!field.key || field.type === "separator") return false - if (allowMultiple) return true - return !filters.some((filter) => filter.field === field.key) - }) - }, [fields, filters, allowMultiple]) - - const filteredFields = useMemo(() => { - return selectableFields.filter( - (f) => - !menuSearchInput || - f.label?.toLowerCase().includes(menuSearchInput.toLowerCase()) - ) - }, [selectableFields, menuSearchInput]) - - useEffect(() => { - if (addFilterOpen && filteredFields.length > 0) { - setHighlightedIndex(0) - } - }, [addFilterOpen, filteredFields.length]) - - const triggerButton = useRender({ - render: trigger as React.ReactElement, - defaultTagName: "button", - }) - - return ( - -
- {selectableFields.length > 0 && ( - { - setAddFilterOpen(open) - if (!open) { - setMenuSearchInput("") - setSessionFilterIds({}) - } else { - setActiveMenu("root") - } - }} - > - - - {showSearchInput && ( - <> -
- = 0 - ? `${rootId}-item-${highlightedIndex}` - : undefined - } - placeholder={mergedI18n.searchFields} - className={cn( - "h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none", - "focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0", - activeMenu === "root" && "placeholder:text-foreground" - )} - value={menuSearchInput} - onFocus={() => setActiveMenu("root")} - onMouseEnter={() => setActiveMenu("root")} - onBlur={() => - activeMenu === "root" && rootInputRef.current?.focus() - } - onChange={(e) => setMenuSearchInput(e.target.value)} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "ArrowDown") { - e.preventDefault() - if (filteredFields.length > 0) { - setHighlightedIndex((prev) => - prev < filteredFields.length - 1 ? prev + 1 : 0 - ) - } - } else if (e.key === "ArrowUp") { - e.preventDefault() - if (filteredFields.length > 0) { - setHighlightedIndex((prev) => - prev > 0 ? prev - 1 : filteredFields.length - 1 - ) - } - } else if ( - (e.key === "ArrowRight" || e.key === "ArrowLeft") && - highlightedIndex >= 0 - ) { - const field = filteredFields[highlightedIndex] - const hasSubMenu = - field && - (field.type === "select" || - field.type === "multiselect") && - field.options?.length - - if (e.key === "ArrowRight" && hasSubMenu) { - e.preventDefault() - setOpenSubMenu(field.key || null) - setActiveMenu(field.key || "root") - } else if (e.key === "ArrowLeft") { - e.preventDefault() - if (openSubMenu) { - setOpenSubMenu(null) - setActiveMenu("root") - } - } - } else if (e.key === "Enter" && highlightedIndex >= 0) { - e.preventDefault() - const field = filteredFields[highlightedIndex] - if (field.key) { - const hasSubMenu = - (field.type === "select" || - field.type === "multiselect") && - field.options?.length - if (!hasSubMenu) { - addFilter(field.key) - } else { - if (openSubMenu === field.key) { - setOpenSubMenu(null) - setActiveMenu("root") - } else { - setOpenSubMenu(field.key) - setActiveMenu(field.key) - } - } - } - } else if (e.key === "Escape") { - setAddFilterOpen(false) - } - e.stopPropagation() - }} - /> - {enableShortcut && shortcutLabel && ( - - {shortcutLabel} - - )} -
- - - )} - -
-
setActiveMenu("root")} - > - - {(() => { - if (filteredFields.length === 0) { - return ( -
- {mergedI18n.noFieldsFound} -
- ) - } - - return filteredFields.map((field, index) => { - const isHighlighted = highlightedIndex === index - const itemId = `${rootId}-item-${index}` - const hasSubMenu = - (field.type === "select" || - field.type === "multiselect") && - field.options?.length - - if (hasSubMenu) { - const isMultiSelect = field.type === "multiselect" - const fieldKey = field.key as string - const sessionFilterId = sessionFilterIds[fieldKey] - const sessionFilter = sessionFilterId - ? filters.find((f) => f.id === sessionFilterId) - : null - const currentValues = sessionFilter?.values || [] - - return ( - { - if (open) { - setOpenSubMenu(fieldKey) - } else { - if (openSubMenu === fieldKey) { - setOpenSubMenu(null) - setActiveMenu("root") - } - } - }} - > - { - setHighlightedIndex(index) - setActiveMenu("root") - }} - className="data-popup-open:bg-accent data-popup-open:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground" - > - {field.icon} - {field.label} - - - { - if (field.searchable !== false) { - setActiveMenu(fieldKey) - } - }} - onBack={() => { - setOpenSubMenu(null) - setActiveMenu("root") - }} - onClose={() => setAddFilterOpen(false)} - onToggle={(value, isSelected) => { - if (isMultiSelect) { - const nextValues = isSelected - ? (currentValues.filter( - (v) => v !== value - ) as T[]) - : ([...currentValues, value] as T[]) - - if (sessionFilter) { - if (nextValues.length === 0) { - onChange( - filters.filter( - (f) => f.id !== sessionFilter.id - ) - ) - setSessionFilterIds((prev) => ({ - ...prev, - [fieldKey]: "", - })) - } else { - onChange( - filters.map((f) => - f.id === sessionFilter.id - ? { ...f, values: nextValues } - : f - ) - ) - } - } else { - const newFilter = createFilter( - fieldKey, - field.defaultOperator || "is_any_of", - nextValues - ) - onChange([...filters, newFilter]) - setSessionFilterIds((prev) => ({ - ...prev, - [fieldKey]: newFilter.id, - })) - } - } else { - const newFilter = createFilter( - fieldKey, - field.defaultOperator || "is", - [value] as T[] - ) - setLastAddedFilterId(newFilter.id) - onChange([...filters, newFilter]) - setAddFilterOpen(false) - } - }} - /> - - - ) - } - - return ( - setHighlightedIndex(index)} - onClick={() => field.key && addFilter(field.key)} - className="data-highlighted:bg-accent data-highlighted:text-accent-foreground" - > - {field.icon} - {field.label} - - ) - }) - })()} -
-
-
-
-
- )} - - {filters.map((filter) => { - const field = fieldsMap[filter.field] - if (!field) return null - return ( - - - {field.icon && field.icon} - {field.label} - - - field={field} - operator={filter.operator} - values={filter.values} - onChange={(operator) => updateFilter(filter.id, { operator })} - /> - - field={field} - values={filter.values} - operator={filter.operator} - onChange={(values) => updateFilter(filter.id, { values })} - autoFocus={filter.id === lastAddedFilterId} - /> - removeFilter(filter.id)} /> - - ) - })} -
-
- ) -} - -export const createFilter = ( - field: string, - operator?: string, - values: T[] = [] -): Filter => ({ - id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, - field, - operator: operator || "is", - values, -}) - -export const createFilterGroup = ( - id: string, - label: string, - fields: FilterFieldConfig[], - initialFilters: Filter[] = [] -): FilterGroup => ({ - id, - label, - filters: initialFilters, - fields, -}) \ No newline at end of file diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 08def05d..4f25fe74 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -18,7 +18,7 @@ | 1 | Достоверность сохранения, удаления, включения и опроса серверов | Завершён 2026-09-13 | | 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | Завершён 2026-09-13, с перечисленными ограничениями приёмки | | 3 | Основные таблицы и справочники | Завершён 2026-09-13, границы проверок ниже | -| 4 | Остальные таблицы, удаление старых API, общая регрессия | В работе: BGP/OSPF, VPN, пользователи, сертификаты и бэкапы перенесены | +| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Не начат | | 6 | Навигация и дашборд | Не начат | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Не начат | @@ -346,9 +346,30 @@ TypeScript до и после первого браузерного прогон На последнем чтении использовано 62% пятичасовой и 99% недельной квоты. Старые Filters/fallback/helper ещё физически остаются, поэтому этап 4 НЕ завершён. Следующий шаг — удалить неиспользуемый fallback DataPageToolbar и старые Filters/evaluator после проверки импортов, обновить тест совместимости, затем выполнить полную регрессию и production build. Сохранить этот блок локальным коммитом без push. +## Этап 4 — завершение: удаление старого Filters (2026-09-20) + +Удалены `components/reui/filters.tsx`, `apply-reui-filters.ts` и временный `migrate-filter-query.ts`. У DataPageToolbar удалены устаревшие props filters/onFiltersChange/filterFields и их fallback; все четыре действующих потребителя используют filterControl и FilterQuery. Props filters/onFiltersChange у BackupsHistory остаются внутренним контрактом нового FilterQuery, а не старого Filters. Unit-тест сравнения двух реализаций заменён независимыми ожидаемыми результатами для шести операторов с AND. + +| Финальная проверка миграции | Результат | +| --- | --- | +| Поиск старых FilterFieldConfig/applyReuiFilters/migrateFilterQuery, legacy-data-grid и react-table-v8 в app/components/lib/tests/package.json/package-lock.json | Совпадений нет | +| Unit-тесты дерева FilterQuery | 7 passed | +| Unit-тесты адаптера статистики | 5 passed | +| TypeScript без incremental | Успешно | +| ESLint изменённых файлов | Успешно | +| Полная браузерная регрессия | 51 passed, 2.4 минуты | +| Production build | Успешно, 34 статические страницы | +| git diff --check изменённых файлов | Успешно | + +Получен актуальный ReUI MCP `get_audit_checklist`: в этом блоке сохранены установленная композиция, публичный API и тема, новых данных/интерактивных элементов/секретов не добавлено. Миграция Data Grid/TanStack v9 и Filters завершена; старые временные API больше не нужны. Это завершение технической миграции, а не полной UX/UI-приёмки: остаются ранее записанные ограничения адаптивности/доступности, исходные lint-долги страниц, имитация Community apply и оценки в оптимизаторе. Реальные RouterOS, права на сетевые операции и все состояния backend по мокам не подтверждены. Полный repository lint не заявляется чистым; профильные проверки изменённых участков и production build прошли. + +Квота: 2% пятичасовой/0% недельной использовано на старте; после регрессии — 32%/5%. Этап 4 сохранить локальным коммитом без push. Изменения skills/AGENTS/.codex другой работы не включать. Общую регрессию этапа 4 считать выполненной; автоматический merge, push или релиз не выполнялись. + +**Следующий этап 5:** `hooks/use-dashboard-live.ts` и `app/(main)/dashboard/page.tsx`. Подтверждено чтением: fetchSnapshot уже использует Promise.allSettled, но при ошибках очищает probes/servers/BGP/traffic/overlay, а fetchRecentEvents очищает события; обновления запускаются из нескольких effects и таймеров без защиты результатов запросов. Ввести состояния/свежесть по источникам и сохранение последних успешных данных; отличать начальную ошибку от частичного сбоя и фонового обновления. Защитить смену backend/mode и перекрывающиеся запросы от поздних результатов, включая дополнительные запросы Internet Path. Проверить HTTP-моками первоначальную ошибку, частичный сбой, повторное восстановление и гонки; профильный UX показывать без подмены неизвестных данных нулями. Зафиксировать отдельным коммитом этапа 5. + ## Карта миграции (исходная инвентаризация этапа 0) -Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile. +Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация. Источники: [контракт UI](ui-design-contract.md), [таблицы PRO](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp), [API Data Grid](https://reui.io/docs/components/base/data-grid?ref=mcp), [API Filters](https://reui.io/docs/components/base/filters?ref=mcp). diff --git a/lib/data-filters/apply-filter-query.test.ts b/lib/data-filters/apply-filter-query.test.ts index 5c4a0b75..d32bc850 100644 --- a/lib/data-filters/apply-filter-query.test.ts +++ b/lib/data-filters/apply-filter-query.test.ts @@ -1,8 +1,6 @@ import assert from "node:assert/strict" import { test } from "node:test" import { applyFilterQuery } from "./apply-filter-query" -import { migrateFilterQuery } from "./migrate-filter-query" -import { applyReuiFilters } from "./apply-reui-filters" import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query" const rows = [{ country: "RU", status: "online" }, { country: "DE", status: "offline" }, { country: "NL", status: "offline" }] @@ -36,10 +34,17 @@ test("text prefix and suffix use case-insensitive matching and support negation" assert.deepEqual(applyFilterQuery(rows, createFilterQuery([{ ...rule("country", "starts_with", "r"), negated: true }]), accessors), rows.slice(1)) }) -test("migration preserves the existing operators and implicit AND", () => { - for (const operator of ["is", "isNot", "isAnyOf", "isNotAnyOf", "contains", "doesNotContain"]) { - const filters = [{ id: "country", field: "country", operator, values: ["RU", "DE"] }, - { id: "status", field: "status", operator: "isNot", values: ["online"] }] - assert.deepEqual(applyFilterQuery(rows, migrateFilterQuery(filters), accessors), applyReuiFilters(rows, filters, accessors)) +test("scalar operators keep their expected results when combined with AND", () => { + const cases: [string, unknown, typeof rows][] = [ + ["is", "RU", []], + ["is_not", "RU", [rows[1], rows[2]]], + ["is_any_of", ["RU", "DE"], [rows[1]]], + ["is_none_of", ["RU", "DE"], [rows[2]]], + ["contains", "e", [rows[1]]], + ["not_contains", "e", [rows[2]]], + ] + for (const [operator, value, expected] of cases) { + const query = createFilterQuery([rule("country", operator, value), rule("status", "is_not", "online")]) + assert.deepEqual(applyFilterQuery(rows, query, accessors), expected) } }) diff --git a/lib/data-filters/apply-reui-filters.ts b/lib/data-filters/apply-reui-filters.ts deleted file mode 100644 index fa1c3db4..00000000 --- a/lib/data-filters/apply-reui-filters.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Filter } from "@/components/reui/filters" - -type FieldAccessor = Record unknown> - -function matchFilter(row: T, filter: Filter, accessors: FieldAccessor): boolean { - const getValue = accessors[filter.field] - if (!getValue) return true - - const value = getValue(row) - const vals = filter.values - const str = (v: unknown) => String(v ?? "").toLowerCase() - - switch (filter.operator) { - case "is": - return vals.length > 0 && String(value) === String(vals[0]) - case "isNot": - return vals.length === 0 || String(value) !== String(vals[0]) - case "isAnyOf": - return vals.some((v) => String(value) === String(v)) - case "isNotAnyOf": - return !vals.some((v) => String(value) === String(v)) - case "contains": - return vals.some((v) => str(value).includes(str(v))) - case "doesNotContain": - return !vals.some((v) => str(value).includes(str(v))) - default: - return true - } -} - -export function applyReuiFilters( - rows: T[], - filters: Filter[], - accessors: FieldAccessor, -): T[] { - if (filters.length === 0) return rows - return rows.filter((row) => - filters.every((f) => matchFilter(row, f, accessors)), - ) -} diff --git a/lib/data-filters/migrate-filter-query.ts b/lib/data-filters/migrate-filter-query.ts deleted file mode 100644 index 153f1c70..00000000 --- a/lib/data-filters/migrate-filter-query.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createFilterQuery, createFilterRule } from "@/components/reui/filters/filters-query" - -const OPERATORS: Record = { - is: "is", isNot: "is_not", isAnyOf: "is_any_of", isNotAnyOf: "is_none_of", - contains: "contains", doesNotContain: "not_contains", -} - -/** Temporary input contract, independent of the old UI component. */ -export function migrateFilterQuery(filters: { id: string; field: string; operator: string; values: unknown[] }[]) { - return createFilterQuery(filters.map(filter => { - const operator = OPERATORS[filter.operator] - if (!operator) throw new Error(`Неподдерживаемое условие: ${filter.operator}`) - return createFilterRule({ id: filter.id, path: [filter.field], operator, value: filter.values }) - })) -}