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

This commit is contained in:
2025-12-01 16:13:36 +07:00
parent 4655fc67cc
commit b3295ebfae
2 changed files with 218 additions and 14 deletions
+17 -14
View File
@@ -7,6 +7,7 @@ import Pagination from './components/Pagination.jsx';
import PageHeader from './components/PageHeader.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import FormField from './components/FormField.jsx';
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import {
IconPlus,
@@ -1622,15 +1623,16 @@ function AddBillingForm({ item, onItemChange, servers }) {
/>
</div>
<div className="col-md-6">
<FormField
label="Связанный сервер (опционально)"
name="serverId"
type="select"
<label className="form-label">Связанный сервер (опционально)</label>
<ServerAutocompleteInput
value={item.serverId || ''}
onChange={(value) => onItemChange({ ...item, serverId: value })}
options={serverOptions}
helpText="Используется для связи с разделом «Серверы»"
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
servers={servers}
placeholder="Поиск по IP, DNS, провайдеру..."
/>
<div className="form-hint mt-1">
Используется для связи с разделом «Серверы». Можно оставить пустым.
</div>
</div>
<div className="col-md-6">
<FormField
@@ -1775,15 +1777,16 @@ function EditBillingForm({ item, onItemChange, servers }) {
/>
</div>
<div className="col-md-6">
<FormField
label="Связанный сервер (опционально)"
name="serverId"
type="select"
<label className="form-label">Связанный сервер (опционально)</label>
<ServerAutocompleteInput
value={item.serverId || ''}
onChange={(value) => onItemChange({ ...item, serverId: value })}
options={serverOptions}
helpText="Используется для связи с разделом «Серверы»"
onChange={(value) => onItemChange({ ...item, serverId: value || '' })}
servers={servers}
placeholder="Поиск по IP, DNS, провайдеру..."
/>
<div className="form-hint mt-1">
Используется для связи с разделом «Серверы». Можно оставить пустым.
</div>
</div>
<div className="col-md-6">
<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;