feat(FilterManager): implement drag-and-drop functionality for filter reordering; integrate @dnd-kit libraries for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s

This commit is contained in:
2026-01-20 16:53:49 +07:00
parent f1aecf7529
commit 1ec3b3cf24
3 changed files with 308 additions and 100 deletions
+62
View File
@@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tabler/core": "^1.3.2",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.56.2",
@@ -290,6 +293,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"cpu": [
@@ -2213,6 +2269,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
+5 -2
View File
@@ -12,15 +12,18 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tabler/core": "^1.3.2",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-virtual": "^3.10.8",
"@xyflow/react": "^12.3.4",
"axios": "^1.10.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1",
"@xyflow/react": "^12.3.4"
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
+241 -98
View File
@@ -31,8 +31,25 @@ import {
IconSortAscending,
IconSortDescending,
IconWorld,
IconFileText
IconFileText,
IconGripVertical
} from '@tabler/icons-react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
import {
AddFilterModal,
@@ -514,6 +531,138 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
);
}
// Компонент перетаскиваемой строки фильтра
function SortableFilterRow({
filter,
index,
filterKey,
isSelected,
onToggleSelect,
onEdit,
onDelete,
communities,
baseAS,
selectedServerGateways
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: filterKey });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? 'var(--tblr-bg-surface-secondary)' : undefined,
};
return (
<tr ref={setNodeRef} style={style}>
<td>
<div
{...attributes}
{...listeners}
className="cursor-grab d-flex align-items-center justify-content-center"
style={{ touchAction: 'none' }}
>
<IconGripVertical size={16} className="text-muted" />
</div>
</td>
<td>
<input
className="form-check-input m-0 align-middle"
type="checkbox"
aria-label="Выбрать строку"
checked={isSelected}
onChange={onToggleSelect}
/>
</td>
<td>
<span className="badge bg-secondary-lt text-muted me-2">#{index + 1}</span>
</td>
<td>
<div className="d-flex align-items-start">
<span className="badge bg-blue-lt text-blue me-2 mt-1">
<IconHash size={12} />
</span>
<div className="flex-grow-1">
<code className="text-blue">{filter?.community || ''}</code>
{(() => {
const communityData = communities.find(c => {
if (c.value === filter?.community) return true;
const normalized = c.value.includes(':') ? c.value : `${baseAS}:${c.value}`;
return normalized === filter?.community;
});
if (communityData && (communityData.name || communityData.description)) {
return (
<div className="text-muted small mt-1">
{communityData.name && <div className="fw-bold">{communityData.name}</div>}
{communityData.description && <div>{communityData.description}</div>}
</div>
);
}
return null;
})()}
</div>
</div>
</td>
<td>
<div className="d-flex align-items-start">
<span className="badge bg-green-lt text-green me-2 mt-1">
<IconServer size={12} />
</span>
<div className="flex-grow-1">
<code className="text-green">{filter?.gateway || ''}</code>
{(() => {
const gatewayData = selectedServerGateways.find(gw => gw.name === filter?.gateway);
if (gatewayData && (gatewayData.ip || gatewayData.comment)) {
return (
<div className="text-muted small mt-1">
{gatewayData.ip && <div className="fw-bold">{gatewayData.ip}</div>}
{gatewayData.comment && <div>{gatewayData.comment}</div>}
</div>
);
}
return null;
})()}
</div>
</div>
</td>
<td>
<div className="text-truncate" style={{ maxWidth: '250px' }} title={filter?.description || ''}>
{filter?.description ? (
<span className="text-muted">{filter.description}</span>
) : (
<span className="text-muted fst-italic">Без описания</span>
)}
</div>
</td>
<td className="text-end">
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={onEdit}
title="Редактировать фильтр"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={onDelete}
title="Удалить фильтр"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
}
function FilterManager() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
@@ -1338,6 +1487,47 @@ function FilterManager() {
}
};
// Drag and drop sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Обработчик завершения перетаскивания
const handleDragEnd = async (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
// Находим индексы в исходном массиве serverFilters
const oldIndex = serverFilters.findIndex(f => filterKey(f) === active.id);
const newIndex = serverFilters.findIndex(f => filterKey(f) === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// Перемещаем элемент
const newFilters = arrayMove(serverFilters, oldIndex, newIndex);
setServerFilters(newFilters);
// Сохраняем новый порядок
try {
await api.post(`/server-filters/${selectedServer.id}`, { filters: newFilters });
setSuccess('Порядок фильтров обновлён');
setTimeout(() => setSuccess(''), 2000);
} catch (error) {
console.error('Error saving filter order:', error);
setError('Не удалось сохранить порядок фильтров');
// Откатываем изменения
setServerFilters(serverFilters);
}
};
// Убрано сохранение режима в localStorage по просьбе пользователя
const toggleSelectFilter = (key) => {
@@ -1807,10 +1997,20 @@ function FilterManager() {
</div>
) : (
<>
<div className="alert alert-info mb-3 d-flex align-items-center">
<IconGripVertical size={18} className="me-2" />
<span>Перетащите строки за иконку <IconGripVertical size={14} className="mx-1" /> чтобы изменить приоритет правил</span>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}></th>
<th className="w-1">
<input
className="form-check-input m-0 align-middle"
@@ -1820,121 +2020,64 @@ function FilterManager() {
onChange={toggleSelectAllProcessed}
/>
</th>
<th style={{ width: '25%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
<th style={{ width: '60px' }}>#</th>
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('community')}>
<span className="me-1">COMMUNITY</span>
{sortBy === 'community' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '25%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
<th style={{ width: '22%' }} className="cursor-pointer" onClick={() => toggleSort('gateway')}>
<span className="me-1">GATEWAY</span>
{sortBy === 'gateway' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '35%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
<th style={{ width: '30%' }} className="cursor-pointer" onClick={() => toggleSort('description')}>
<span className="me-1">ОПИСАНИЕ</span>
{sortBy === 'description' && (sortDir === 'asc' ? <IconSortAscending size={16} /> : <IconSortDescending size={16} />)}
</th>
<th style={{ width: '15%' }} className="text-end">ДЕЙСТВИЯ</th>
<th style={{ width: '120px' }} className="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<SortableContext
items={serverFilters.filter(f => f != null).map(f => filterKey(f))}
strategy={verticalListSortingStrategy}
>
<tbody>
{processedFilters.filter(f => f != null).map((filter, index) => {
{serverFilters.filter(f => f != null).map((filter, index) => {
const key = filterKey(filter);
const isSelected = selectedFilterKeys.has(key);
// Показываем только если совпадает с поиском
if (filterSearch.trim()) {
const search = filterSearch.trim().toLowerCase();
const c = String(filter.community || '').toLowerCase();
const g = String(filter.gateway || '').toLowerCase();
const d = String(filter.description || '').toLowerCase();
if (!c.includes(search) && !g.includes(search) && !d.includes(search)) {
return null;
}
}
return (
<tr key={`${filter?.community || ''}-${index}`}>
<td>
<input
className="form-check-input m-0 align-middle"
type="checkbox"
aria-label="Выбрать строку"
checked={isSelected}
onChange={() => toggleSelectFilter(key)}
/>
</td>
<td>
<div className="d-flex align-items-start">
<span className="badge bg-blue-lt text-blue me-2 mt-1">
<IconHash size={12} />
</span>
<div className="flex-grow-1">
<code className="text-blue">{filter?.community || ''}</code>
{(() => {
// Ищем community в справочнике
const communityData = communities.find(c => {
if (c.value === filter?.community) return true;
const normalized = c.value.includes(':') ? c.value : `${baseAS}:${c.value}`;
return normalized === filter?.community;
});
if (communityData && (communityData.name || communityData.description)) {
return (
<div className="text-muted small mt-1">
{communityData.name && <div className="fw-bold">{communityData.name}</div>}
{communityData.description && <div>{communityData.description}</div>}
</div>
);
}
return null;
})()}
</div>
</div>
</td>
<td>
<div className="d-flex align-items-start">
<span className="badge bg-green-lt text-green me-2 mt-1">
<IconServer size={12} />
</span>
<div className="flex-grow-1">
<code className="text-green">{filter?.gateway || ''}</code>
{(() => {
// Ищем gateway в списке шлюзов сервера
const gatewayData = selectedServerGateways.find(gw => gw.name === filter?.gateway);
if (gatewayData && (gatewayData.ip || gatewayData.comment)) {
return (
<div className="text-muted small mt-1">
{gatewayData.ip && <div className="fw-bold">{gatewayData.ip}</div>}
{gatewayData.comment && <div>{gatewayData.comment}</div>}
</div>
);
}
return null;
})()}
</div>
</div>
</td>
<td>
<div className="text-truncate" style={{ maxWidth: '300px' }} title={filter?.description || ''}>
{filter?.description ? (
<span className="text-muted">{filter.description}</span>
) : (
<span className="text-muted fst-italic">Без описания</span>
)}
</div>
</td>
<td className="text-end">
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleEditFilter(filter, index)}
title="Редактировать фильтр"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => {
setFilterToDelete(filter);
setDeleteFilterModalOpen(true);
}}
title="Удалить фильтр"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);})}
<SortableFilterRow
key={key}
filter={filter}
index={index}
filterKey={key}
isSelected={isSelected}
onToggleSelect={() => toggleSelectFilter(key)}
onEdit={() => handleEditFilter(filter, index)}
onDelete={() => {
setFilterToDelete(filter);
setDeleteFilterModalOpen(true);
}}
communities={communities}
baseAS={baseAS}
selectedServerGateways={selectedServerGateways}
/>
);
})}
</tbody>
</SortableContext>
</table>
</div>
</DndContext>
{selectedFilterKeys.size > 0 && (
<div className="d-flex justify-content-between align-items-center mt-3">
<div className="text-muted">Выбрано: {selectedFilterKeys.size}</div>