feat: Replace input fields with CommunityAutocompleteInput in ASNs, Domains, and IPRanges managers for enhanced user experience and streamlined community selection
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m37s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m37s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -286,13 +287,12 @@ function ASNsNewManager() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community</label>
|
||||
<input
|
||||
list="community-options"
|
||||
type="text"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
placeholder="112"
|
||||
<CommunityAutocompleteInput
|
||||
value={newItem.community}
|
||||
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
|
||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||
communities={communities}
|
||||
placeholder="Начните вводить номер или имя"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
/>
|
||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
||||
{(() => {
|
||||
@@ -439,15 +439,11 @@ function ASNsNewManager() {
|
||||
<td className="text-end">
|
||||
{editingAsn === item.asn ? (
|
||||
<>
|
||||
<input
|
||||
list="community-options"
|
||||
type="text"
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
<CommunityAutocompleteInput
|
||||
value={editingValue}
|
||||
ref={editInputRef}
|
||||
onChange={e => setEditingValue(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, item.asn)}
|
||||
style={{maxWidth: 120}}
|
||||
onChange={setEditingValue}
|
||||
communities={communities}
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
/>
|
||||
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={18} /></button>
|
||||
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||||
@@ -553,15 +549,7 @@ function ASNsNewManager() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Общий список подсказок community */}
|
||||
<datalist id="community-options">
|
||||
{communities.map(c => {
|
||||
const label = c.name ? `${c.value} - ${c.name}` : `${c.value}`;
|
||||
return (
|
||||
<option key={c.value} value={c.value} label={label}>{label}</option>
|
||||
);
|
||||
})}
|
||||
</datalist>
|
||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -292,13 +293,12 @@ function DomainsNewManager() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community</label>
|
||||
<input
|
||||
list="community-options"
|
||||
type="text"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
placeholder="100"
|
||||
<CommunityAutocompleteInput
|
||||
value={newItem.community}
|
||||
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
|
||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||
communities={communities}
|
||||
placeholder="Начните вводить номер или имя"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
/>
|
||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
||||
{(() => {
|
||||
@@ -445,15 +445,11 @@ function DomainsNewManager() {
|
||||
<td className="text-end">
|
||||
{editingDomain === item.domain ? (
|
||||
<>
|
||||
<input
|
||||
list="community-options"
|
||||
type="text"
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
<CommunityAutocompleteInput
|
||||
value={editingValue}
|
||||
ref={editInputRef}
|
||||
onChange={e => setEditingValue(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, item.domain)}
|
||||
style={{maxWidth: 120}}
|
||||
onChange={setEditingValue}
|
||||
communities={communities}
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
/>
|
||||
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.domain)} disabled={!isValidCommunity(editingValue)}><IconCheck size={18} /></button>
|
||||
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||||
@@ -559,15 +555,7 @@ function DomainsNewManager() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Общий список подсказок community */}
|
||||
<datalist id="community-options">
|
||||
{communities.map(c => {
|
||||
const label = c.name ? `${c.value} - ${c.name}` : `${c.value}`;
|
||||
return (
|
||||
<option key={c.value} value={c.value} label={label}>{label}</option>
|
||||
);
|
||||
})}
|
||||
</datalist>
|
||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
@@ -301,13 +302,12 @@ function IPRangesManager() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community</label>
|
||||
<input
|
||||
list="community-options"
|
||||
type="text"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
placeholder="65001:100"
|
||||
<CommunityAutocompleteInput
|
||||
value={newItem.community}
|
||||
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
|
||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
||||
communities={communities}
|
||||
placeholder="Начните вводить номер или имя"
|
||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
||||
/>
|
||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
||||
{(() => {
|
||||
@@ -454,15 +454,11 @@ function IPRangesManager() {
|
||||
<td className="text-end">
|
||||
{editingIp === item.ipRange ? (
|
||||
<>
|
||||
<input
|
||||
list="community-options"
|
||||
type="text"
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
<CommunityAutocompleteInput
|
||||
value={editingValue}
|
||||
ref={editInputRef}
|
||||
onChange={e => setEditingValue(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, item.ipRange)}
|
||||
style={{maxWidth: 120}}
|
||||
onChange={setEditingValue}
|
||||
communities={communities}
|
||||
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
||||
/>
|
||||
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={18} /></button>
|
||||
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||||
@@ -573,15 +569,7 @@ function IPRangesManager() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Общий список подсказок community */}
|
||||
<datalist id="community-options">
|
||||
{communities.map(c => {
|
||||
const label = c.name ? `${c.value} - ${c.name}` : `${c.value}`;
|
||||
return (
|
||||
<option key={c.value} value={c.value} label={label}>{label}</option>
|
||||
);
|
||||
})}
|
||||
</datalist>
|
||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IconHash } from '@tabler/icons-react';
|
||||
|
||||
function CommunityAutocompleteInput({
|
||||
value,
|
||||
onChange,
|
||||
communities = [],
|
||||
placeholder = '',
|
||||
className = 'form-control',
|
||||
onSelectMeta,
|
||||
maxSuggestions = 8,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const q = String(value || '').toLowerCase();
|
||||
if (!q) return communities.slice(0, maxSuggestions);
|
||||
const filtered = communities.filter((c) => {
|
||||
const v = (c.value || '').toLowerCase();
|
||||
const n = (c.name || '').toLowerCase();
|
||||
const d = (c.description || '').toLowerCase();
|
||||
const t = Array.isArray(c.tags) ? c.tags.join(' ').toLowerCase() : '';
|
||||
return v.includes(q) || n.includes(q) || d.includes(q) || t.includes(q);
|
||||
});
|
||||
return filtered.slice(0, maxSuggestions);
|
||||
}, [value, communities, maxSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutside = (e) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('click', handleOutside);
|
||||
return () => document.removeEventListener('click', handleOutside);
|
||||
}, []);
|
||||
|
||||
const selectItem = (item) => {
|
||||
onChange(item.value);
|
||||
if (onSelectMeta) onSelectMeta(item);
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
if (activeIndex >= 0 && activeIndex < suggestions.length) {
|
||||
e.preventDefault();
|
||||
selectItem(suggestions[activeIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="position-relative" style={{ width: '100%' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{open && suggestions.length > 0 && (
|
||||
<div
|
||||
className="dropdown-menu show"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{suggestions.map((s, idx) => {
|
||||
const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`;
|
||||
const description = s.description || '';
|
||||
const tags = Array.isArray(s.tags) ? s.tags : [];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${s.value}-${idx}`}
|
||||
className={`dropdown-item${idx === activeIndex ? ' active' : ''}`}
|
||||
onMouseDown={(e) => { e.preventDefault(); selectItem(s); }}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 22, height: 22 }}>
|
||||
<IconHash size={14} />
|
||||
</span>
|
||||
<div className="flex-fill text-start">
|
||||
<div className="fw-medium">{label}</div>
|
||||
{(description || tags.length > 0) && (
|
||||
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
|
||||
{description}
|
||||
{tags.length > 0 && (
|
||||
<> ({tags.join(', ')})</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommunityAutocompleteInput;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user