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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
This commit is contained in:
Generated
+62
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user