feat: Integrate ServerAutocompleteInput in BillingManager forms for optional server selection, enhancing user experience with improved search functionality and contextual help.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
This commit is contained in:
@@ -7,6 +7,7 @@ import Pagination from './components/Pagination.jsx';
|
|||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
import PageHeaderActions from './components/PageHeaderActions.jsx';
|
||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
|
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
||||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -1622,15 +1623,16 @@ function AddBillingForm({ item, onItemChange, servers }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<FormField
|
<label className="form-label">Связанный сервер (опционально)</label>
|
||||||
label="Связанный сервер (опционально)"
|
<ServerAutocompleteInput
|
||||||
name="serverId"
|
|
||||||
type="select"
|
|
||||||
value={item.serverId || ''}
|
value={item.serverId || ''}
|
||||||
onChange={(value) => onItemChange({ ...item, serverId: value })}
|
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
|
||||||
options={serverOptions}
|
servers={servers}
|
||||||
helpText="Используется для связи с разделом «Серверы»"
|
placeholder="Поиск по IP, DNS, провайдеру..."
|
||||||
/>
|
/>
|
||||||
|
<div className="form-hint mt-1">
|
||||||
|
Используется для связи с разделом «Серверы». Можно оставить пустым.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1775,15 +1777,16 @@ function EditBillingForm({ item, onItemChange, servers }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<FormField
|
<label className="form-label">Связанный сервер (опционально)</label>
|
||||||
label="Связанный сервер (опционально)"
|
<ServerAutocompleteInput
|
||||||
name="serverId"
|
|
||||||
type="select"
|
|
||||||
value={item.serverId || ''}
|
value={item.serverId || ''}
|
||||||
onChange={(value) => onItemChange({ ...item, serverId: value })}
|
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
|
||||||
options={serverOptions}
|
servers={servers}
|
||||||
helpText="Используется для связи с разделом «Серверы»"
|
placeholder="Поиск по IP, DNS, провайдеру..."
|
||||||
/>
|
/>
|
||||||
|
<div className="form-hint mt-1">
|
||||||
|
Используется для связи с разделом «Серверы». Можно оставить пустым.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { IconServer } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Красивый selector серверов с автодополнением.
|
||||||
|
* Похож по UX на CommunityAutocompleteInput, но заточен под поля серверов.
|
||||||
|
*/
|
||||||
|
function ServerAutocompleteInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
servers = [],
|
||||||
|
placeholder = '',
|
||||||
|
className = 'form-control',
|
||||||
|
onSelectMeta,
|
||||||
|
maxSuggestions = 12,
|
||||||
|
}) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
const list = (servers || []).map((s) => {
|
||||||
|
const id = s.id || s.ip;
|
||||||
|
const labelIp = s.ip || '—';
|
||||||
|
const labelDns = s.dns || '';
|
||||||
|
const labelProvider = s.provider || '';
|
||||||
|
const label =
|
||||||
|
labelDns && labelProvider
|
||||||
|
? `${labelIp} (${labelDns}) – ${labelProvider}`
|
||||||
|
: labelDns
|
||||||
|
? `${labelIp} (${labelDns})`
|
||||||
|
: labelProvider
|
||||||
|
? `${labelIp} – ${labelProvider}`
|
||||||
|
: `${labelIp}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: s,
|
||||||
|
value: id,
|
||||||
|
label,
|
||||||
|
search: [
|
||||||
|
id,
|
||||||
|
s.ip,
|
||||||
|
s.extIp,
|
||||||
|
s.internalIp,
|
||||||
|
s.dns,
|
||||||
|
s.provider,
|
||||||
|
s.country,
|
||||||
|
s.gateway,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!q) return list.slice(0, maxSuggestions);
|
||||||
|
const filtered = list.filter((item) => item.search.includes(q));
|
||||||
|
return filtered.slice(0, maxSuggestions);
|
||||||
|
}, [value, servers, 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.raw);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Текст в input: ищем label по выбранному serverId
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (!value) return '';
|
||||||
|
const found = (servers || []).find((s) => (s.id || s.ip) === value);
|
||||||
|
if (!found) return '';
|
||||||
|
const ip = found.ip || '—';
|
||||||
|
const dns = found.dns || '';
|
||||||
|
const provider = found.provider || '';
|
||||||
|
if (dns && provider) return `${ip} (${dns}) – ${provider}`;
|
||||||
|
if (dns) return `${ip} (${dns})`;
|
||||||
|
if (provider) return `${ip} – ${provider}`;
|
||||||
|
return ip;
|
||||||
|
}, [value, servers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="position-relative" style={{ width: '100%' }}>
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconServer size={18} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className={className}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={open ? displayValue || value : displayValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && suggestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="dropdown-menu show"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dropdown-item${activeIndex === -1 ? ' active' : ''}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange('');
|
||||||
|
if (onSelectMeta) onSelectMeta(null);
|
||||||
|
setOpen(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center text-muted small">
|
||||||
|
<span className="me-2">Без привязки</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{suggestions.map((s, idx) => (
|
||||||
|
<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: 24, height: 24 }}>
|
||||||
|
<IconServer size={14} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-fill text-start">
|
||||||
|
<div className="fw-medium">{s.label}</div>
|
||||||
|
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
|
||||||
|
{s.raw.country && <span className="me-2">{s.raw.country}</span>}
|
||||||
|
{s.raw.tunnel && <span className="me-2">{s.raw.tunnel}</span>}
|
||||||
|
{s.raw.gateway && <span className="me-2">{s.raw.gateway}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServerAutocompleteInput;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user