feat: Add CommunityAutocomplete component to FilterManager for enhanced community selection, featuring dynamic filtering, grouping by category, and improved user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m1s
This commit is contained in:
@@ -70,6 +70,222 @@ const countryToFlag = (code) => {
|
|||||||
return String.fromCodePoint(...codePoints);
|
return String.fromCodePoint(...codePoints);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Компонент автокомплита для выбора Community
|
||||||
|
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...' }) {
|
||||||
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value || '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Фильтруем communities по введённому тексту
|
||||||
|
const filteredCommunities = communities.filter(c => {
|
||||||
|
const search = inputValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
(c.value && c.value.toLowerCase().includes(search)) ||
|
||||||
|
(c.name && c.name.toLowerCase().includes(search)) ||
|
||||||
|
(c.description && c.description.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setInputValue(val);
|
||||||
|
onChange(val);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (c) => {
|
||||||
|
setInputValue(c.value);
|
||||||
|
onChange(c.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||||
|
setIsOpen(true);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => Math.min(prev + 1, filteredCommunities.length - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightedIndex >= 0 && filteredCommunities[highlightedIndex]) {
|
||||||
|
handleSelect(filteredCommunities[highlightedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedCommunity = communities.find(c => c.value === value);
|
||||||
|
|
||||||
|
// Группируем по категориям
|
||||||
|
const groupedCommunities = filteredCommunities.reduce((acc, c) => {
|
||||||
|
const cat = c.category || 'Другое';
|
||||||
|
if (!acc[cat]) acc[cat] = [];
|
||||||
|
acc[cat].push(c);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3" ref={containerRef}>
|
||||||
|
<label className={`form-label ${required ? 'required' : ''}`}>{label}</label>
|
||||||
|
<div className="position-relative">
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconHash size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="input-icon-addon cursor-pointer"
|
||||||
|
style={{ right: 0, left: 'auto' }}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<IconChevronDown size={16} className={isOpen ? 'rotate-180' : ''} style={{ transition: 'transform 0.2s' }} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="dropdown-menu show w-100 overflow-auto"
|
||||||
|
style={{
|
||||||
|
maxHeight: '320px',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
zIndex: 1050,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{communities.length === 0 ? (
|
||||||
|
<div className="dropdown-item text-muted">
|
||||||
|
<IconAlertTriangle size={14} className="me-2 text-warning" />
|
||||||
|
Справочник community пуст
|
||||||
|
</div>
|
||||||
|
) : filteredCommunities.length === 0 ? (
|
||||||
|
<div className="dropdown-item text-muted">
|
||||||
|
Ничего не найдено. Нажмите Enter чтобы использовать "{inputValue}"
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{inputValue && !communities.some(c => c.value === inputValue) && (
|
||||||
|
<div className="dropdown-header small text-muted">Свободный ввод: {inputValue}</div>
|
||||||
|
)}
|
||||||
|
{Object.entries(groupedCommunities).map(([category, items]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="dropdown-header small text-muted bg-light">{category}</div>
|
||||||
|
{items.map((c, idx) => {
|
||||||
|
const globalIdx = filteredCommunities.indexOf(c);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.value}
|
||||||
|
className={`dropdown-item cursor-pointer ${highlightedIndex === globalIdx ? 'active' : ''} ${c.value === value ? 'bg-primary-lt' : ''}`}
|
||||||
|
onClick={() => handleSelect(c)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(globalIdx)}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-start gap-2">
|
||||||
|
<span className="avatar avatar-xs bg-blue-lt mt-1">
|
||||||
|
<IconHash size={14} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<code className="fw-bold text-blue">{c.value}</code>
|
||||||
|
{c.name && <span className="text-muted">— {c.name}</span>}
|
||||||
|
</div>
|
||||||
|
{c.description && (
|
||||||
|
<div className="text-muted small text-truncate">{c.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{c.enabled === false && <span className="badge bg-secondary-lt">Выкл</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Показываем выбранный community */}
|
||||||
|
{value && selectedCommunity && !isOpen && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
|
||||||
|
<span className="avatar avatar-xs bg-blue-lt">
|
||||||
|
<IconHash size={14} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<code className="fw-bold text-blue">{selectedCommunity.value}</code>
|
||||||
|
{selectedCommunity.name && <span className="text-muted">— {selectedCommunity.name}</span>}
|
||||||
|
</div>
|
||||||
|
{selectedCommunity.description && (
|
||||||
|
<div className="text-muted small">{selectedCommunity.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedCommunity.category && <span className="badge bg-blue-lt text-blue">{selectedCommunity.category}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Индикация свободного ввода */}
|
||||||
|
{value && !selectedCommunity && !isOpen && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="d-flex align-items-center gap-2 p-2 bg-warning-lt rounded">
|
||||||
|
<IconAlertTriangle size={16} className="text-warning" />
|
||||||
|
<div className="text-muted small">Community "{value}" — свободный ввод (не из справочника)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Компонент автокомплита для выбора Gateway
|
// Компонент автокомплита для выбора Gateway
|
||||||
function GatewayAutocomplete({ label, value, onChange, gateways = [], required = false, placeholder = 'Введите или выберите gateway...' }) {
|
function GatewayAutocomplete({ label, value, onChange, gateways = [], required = false, placeholder = 'Введите или выберите gateway...' }) {
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
@@ -1571,15 +1787,12 @@ function FilterManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
<CommunityAutocomplete
|
||||||
label="Community"
|
label="Community"
|
||||||
name="community"
|
|
||||||
value={newFilter.community}
|
value={newFilter.community}
|
||||||
onChange={(val) => setNewFilter({ ...newFilter, community: val })}
|
onChange={(val) => setNewFilter({ ...newFilter, community: val })}
|
||||||
placeholder="65000:100"
|
communities={communities}
|
||||||
required
|
required
|
||||||
icon={IconHash}
|
|
||||||
helpText="Введите community в формате AS:VALUE"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GatewayAutocomplete
|
<GatewayAutocomplete
|
||||||
@@ -1614,14 +1827,12 @@ function FilterManager() {
|
|||||||
>
|
>
|
||||||
{editingFilter && (
|
{editingFilter && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<CommunityAutocomplete
|
||||||
label="Community"
|
label="Community"
|
||||||
name="community"
|
|
||||||
value={editingFilter.community}
|
value={editingFilter.community}
|
||||||
onChange={(val) => setEditingFilter({ ...editingFilter, community: val })}
|
onChange={(val) => setEditingFilter({ ...editingFilter, community: val })}
|
||||||
placeholder="65000:100"
|
communities={communities}
|
||||||
required
|
required
|
||||||
icon={IconHash}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GatewayAutocomplete
|
<GatewayAutocomplete
|
||||||
|
|||||||
Reference in New Issue
Block a user