UNPKG

mod-arch-shared

Version:

Shared UI components and utilities for modular architecture micro-frontend projects

187 lines 11.8 kB
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Toolbar, ToolbarContent, ToolbarGroup, ToolbarFilter as PFToolbarFilter, ToolbarToggleGroup, ToolbarItem, Select, SelectList, SelectOption, MenuToggle, Badge, } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; import ThemeAwareSearchInput from './ThemeAwareSearchInput'; /** * A unified, reusable toolbar filter component supporting text input, * select dropdown, and multiselect dropdown filters. * * @template K - Union of filter key strings * * @example * ```tsx * const filterConfig = { * name: { type: 'text', label: 'Name', placeholder: 'Filter by name' }, * status: { * type: 'select', * label: 'Status', * placeholder: 'Filter by status', * options: [ * { value: 'Active', label: 'Active' }, * { value: 'Deprecated', label: 'Deprecated' }, * ], * }, * } as const; * * const allKeys = ['name', 'status'] as const; * * <ToolbarFilter * filterConfig={filterConfig} * visibleFilterKeys={allKeys} * filterValues={{ name: '', status: '' }} * onFilterChange={(key, value) => handleChange(key, value)} * onClearAllFilters={() => handleClearAll()} * /> * ``` */ function ToolbarFilterInner({ filterConfig, visibleFilterKeys, filterValues, onFilterChange, onClearAllFilters, toolbarActions, testIdPrefix = 'filter', }, ref) { const [activeFilterKey, setActiveFilterKey] = useState(visibleFilterKeys.length > 0 ? visibleFilterKeys[0] : undefined); const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false); const [isSelectFilterOpen, setIsSelectFilterOpen] = useState(false); const [isMultiselectFilterOpen, setIsMultiselectFilterOpen] = useState(false); const activeFilterLabel = activeFilterKey !== undefined ? filterConfig[activeFilterKey].label : ''; useImperativeHandle(ref, () => ({ clearAll: onClearAllFilters, setFilter: onFilterChange, })); useEffect(() => { if (visibleFilterKeys.length === 0) { setActiveFilterKey(undefined); return; } if (activeFilterKey === undefined || !visibleFilterKeys.includes(activeFilterKey)) { setActiveFilterKey(visibleFilterKeys[0]); } }, [activeFilterKey, visibleFilterKeys]); const onAttributeSelect = useCallback((_ev, itemId) => { const matched = visibleFilterKeys.find((k) => k === itemId); if (matched !== undefined) { setActiveFilterKey(matched); } setIsAttributeMenuOpen(false); }, [visibleFilterKeys]); const onAttributeToggle = useCallback(() => { setIsAttributeMenuOpen((prev) => !prev); }, []); const attributeDropdown = useMemo(() => (React.createElement(Select, { isOpen: isAttributeMenuOpen, selected: activeFilterKey, onSelect: onAttributeSelect, onOpenChange: (isOpen) => setIsAttributeMenuOpen(isOpen), toggle: (toggleRef) => (React.createElement(MenuToggle, { ref: toggleRef, onClick: onAttributeToggle, isExpanded: isAttributeMenuOpen, icon: React.createElement(FilterIcon, null), "data-testid": `${testIdPrefix}-dropdown` }, activeFilterLabel)) }, React.createElement(SelectList, null, visibleFilterKeys.map((key) => (React.createElement(SelectOption, { key: key, value: key, "data-testid": `${testIdPrefix}-dropdown-${key}` }, filterConfig[key].label)))))), [ isAttributeMenuOpen, activeFilterKey, activeFilterLabel, onAttributeSelect, onAttributeToggle, visibleFilterKeys, filterConfig, testIdPrefix, ]); const renderTextFilter = useCallback((key, config) => { const value = filterValues[key]; const textValue = typeof value === 'string' ? value : ''; return (React.createElement(ThemeAwareSearchInput, { value: textValue, onChange: (newValue) => onFilterChange(key, newValue), placeholder: config.placeholder, fieldLabel: config.label, "aria-label": config.placeholder, "data-testid": `${testIdPrefix}-${key}-input` })); }, [filterValues, onFilterChange, testIdPrefix]); const onSelectFilterChange = useCallback((key) => (_ev, value) => { if (value === undefined) { return; } onFilterChange(key, value.toString()); setIsSelectFilterOpen(false); }, [onFilterChange]); const renderSelectFilter = useCallback((key, config) => { const value = filterValues[key]; const selected = typeof value === 'string' ? value : ''; const displayValue = selected ? (config.options.find((option) => option.value === selected)?.label ?? selected) : config.placeholder; return (React.createElement(Select, { isOpen: isSelectFilterOpen && activeFilterKey === key, selected: selected, onSelect: onSelectFilterChange(key), onOpenChange: (isOpen) => setIsSelectFilterOpen(isOpen), toggle: (toggleRef) => (React.createElement(MenuToggle, { ref: toggleRef, onClick: () => setIsSelectFilterOpen((prev) => !prev), isExpanded: isSelectFilterOpen && activeFilterKey === key, style: { width: '200px' }, "data-testid": `${testIdPrefix}-${key}-dropdown` }, displayValue)) }, React.createElement(SelectList, null, config.options.map((option) => (React.createElement(SelectOption, { key: option.value, value: option.value, "data-testid": `${testIdPrefix}-${key}-${option.value.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` }, option.label)))))); }, [isSelectFilterOpen, activeFilterKey, filterValues, onSelectFilterChange, testIdPrefix]); const renderMultiselectFilter = useCallback((key, config) => { const value = filterValues[key]; const selectedValues = Array.isArray(value) ? value : []; const onToggleSelection = (selectedValue) => { const newValues = selectedValues.includes(selectedValue) ? selectedValues.filter((v) => v !== selectedValue) : [...selectedValues, selectedValue]; onFilterChange(key, newValues); }; return (React.createElement(Select, { role: "menu", isOpen: isMultiselectFilterOpen && activeFilterKey === key, selected: selectedValues, onSelect: (_ev, selection) => { if (selection !== undefined) { onToggleSelection(selection.toString()); } }, onOpenChange: (isOpen) => setIsMultiselectFilterOpen(isOpen), toggle: (toggleRef) => (React.createElement(MenuToggle, { ref: toggleRef, onClick: () => setIsMultiselectFilterOpen((prev) => !prev), isExpanded: isMultiselectFilterOpen && activeFilterKey === key, style: { width: '200px' }, "data-testid": `${testIdPrefix}-${key}-dropdown`, badge: selectedValues.length > 0 ? (React.createElement(Badge, { isRead: true }, selectedValues.length)) : undefined }, config.placeholder)) }, React.createElement(SelectList, null, config.options.map((option) => (React.createElement(SelectOption, { key: option.value, value: option.value, hasCheckbox: true, isSelected: selectedValues.includes(option.value), "data-testid": `${testIdPrefix}-${key}-${option.value.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` }, option.label)))))); }, [isMultiselectFilterOpen, activeFilterKey, filterValues, onFilterChange, testIdPrefix]); const renderFilterInput = useCallback((key) => { const config = filterConfig[key]; if (config.type === 'text') { return renderTextFilter(key, config); } if (config.type === 'multiselect') { return renderMultiselectFilter(key, config); } return renderSelectFilter(key, config); }, [filterConfig, renderTextFilter, renderSelectFilter, renderMultiselectFilter]); // For text filters the raw value is the display string, so return plain strings. // For select/multiselect, embed the raw option value in chip.key so the delete // handler can remove the correct entry without a label→value reverse lookup. const getFilterLabels = (key) => { const config = filterConfig[key]; const value = filterValues[key]; if (!('options' in config)) { if (Array.isArray(value)) { return value; } return value ? [value] : []; } const toChip = (rawValue) => ({ key: rawValue, node: config.options.find((opt) => opt.value === rawValue)?.label ?? rawValue, }); if (Array.isArray(value)) { return value.map(toChip); } return value ? [toChip(value)] : []; }; // labelToDelete is always the raw value: plain string for text filters, // chip.key (raw option value) for select/multiselect — no reverse lookup needed. const handleDeleteLabel = (key, labelToDelete) => { const config = filterConfig[key]; if (config.type === 'multiselect' && labelToDelete !== undefined) { const value = filterValues[key]; const currentValues = Array.isArray(value) ? value : []; onFilterChange(key, currentValues.filter((v) => v !== labelToDelete)); } else { onFilterChange(key, config.type === 'multiselect' ? [] : ''); } }; // Object.keys returns string[] by design, but filterConfig is FilterConfigMap<K> // so its keys are exactly K — the cast is structurally correct here. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const hiddenActiveKeys = Object.keys(filterConfig).filter((key) => { const value = filterValues[key]; const hasValue = Array.isArray(value) ? value.length > 0 : Boolean(value); return !visibleFilterKeys.includes(key) && hasValue; }); return (React.createElement(Toolbar, { id: `${testIdPrefix}-toolbar`, clearAllFilters: onClearAllFilters, "data-testid": `${testIdPrefix}-toolbar` }, React.createElement(ToolbarContent, null, React.createElement(ToolbarToggleGroup, { toggleIcon: React.createElement(FilterIcon, null), breakpoint: "xl" }, React.createElement(ToolbarGroup, { variant: "filter-group" }, visibleFilterKeys.length > 0 && (React.createElement(React.Fragment, null, React.createElement(ToolbarItem, null, attributeDropdown), visibleFilterKeys.map((key) => (React.createElement(PFToolbarFilter, { key: key, labels: getFilterLabels(key), deleteLabel: (_category, label) => { const labelStr = typeof label === 'string' ? label : label.key; handleDeleteLabel(key, labelStr); }, deleteLabelGroup: () => handleDeleteLabel(key), categoryName: filterConfig[key].label, showToolbarItem: activeFilterKey === key }, renderFilterInput(key)))))), hiddenActiveKeys.map((key) => (React.createElement(PFToolbarFilter, { key: key, labels: getFilterLabels(key), deleteLabel: (_category, label) => { const labelStr = typeof label === 'string' ? label : label.key; handleDeleteLabel(key, labelStr); }, deleteLabelGroup: () => handleDeleteLabel(key), categoryName: filterConfig[key].label }, null))), toolbarActions))))); } // Standard pattern for generic forwardRef components in TypeScript — the cast // is required to preserve the generic parameter K through the forwardRef wrapper. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const ToolbarFilter = React.forwardRef(ToolbarFilterInner); export default ToolbarFilter; //# sourceMappingURL=ToolbarFilter.js.map