mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 00:55:01 +03:00
feat(xray): drag to reorder DNS servers (#6959)
Xray queries DNS servers in list order, but the DNS table offered only Edit and Delete, so changing a server's priority meant deleting and re-adding servers. Each row now has a grab handle that reorders by drag and drop, using the same pointer hook and drop markers as the Routing rules table, and the row menu gains Move up and Move down. Every path only reorders dns.servers; a server's own fields are left as they are.
This commit is contained in:
committed by
GitHub
parent
166d5633cc
commit
51f7ba47f7
@@ -27,6 +27,7 @@ import { selectTagRender } from '@/components/form';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { usePointerRowReorder } from '@/hooks/usePointerRowReorder';
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import DnsServerModal from './DnsServerModal';
|
||||
import type { DnsServerValue } from './DnsServerModal';
|
||||
@@ -52,6 +53,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
const [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [presetsModalOpen, setPresetsModalOpen] = useState(false);
|
||||
const serversContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
|
||||
const dnsEnabled = !!dns;
|
||||
@@ -166,8 +168,33 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
const moveServer = useCallback(
|
||||
(from: number, to: number) => {
|
||||
mutate((tt) => {
|
||||
const servers = (tt.dns as DnsConfig | undefined)?.servers;
|
||||
if (!servers) return;
|
||||
const [moved] = servers.splice(from, 1);
|
||||
servers.splice(to, 0, moved);
|
||||
});
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
|
||||
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
|
||||
containerRef: serversContainerRef,
|
||||
getRowKey: (row) => Number(row.dataset.rowKey),
|
||||
onDrop: ({ from, to }) => moveServer(from, to),
|
||||
});
|
||||
const draggedIndex = drag?.from ?? null;
|
||||
const dropTargetIndex = drag?.to ?? null;
|
||||
|
||||
const dnsColumns = useDnsServerColumns({
|
||||
rowsLength: dnsServers.length,
|
||||
onHandlePointerDown,
|
||||
openEditServer,
|
||||
moveServer,
|
||||
deleteServer,
|
||||
});
|
||||
|
||||
function openAddServer() {
|
||||
setEditingServer(null);
|
||||
@@ -475,14 +502,21 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
{t('pages.xray.dns.clearAll')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
columns={dnsColumns}
|
||||
dataSource={dnsServers}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
<div ref={serversContainerRef}>
|
||||
<Table
|
||||
columns={dnsColumns}
|
||||
dataSource={dnsServers}
|
||||
rowKey={(r) => r.key}
|
||||
rowClassName={(_r, i) => {
|
||||
if (i === draggedIndex) return 'row-dragging';
|
||||
if (i !== dropTargetIndex || draggedIndex == null) return '';
|
||||
return i > draggedIndex ? 'drop-after' : 'drop-before';
|
||||
}}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
@@ -517,7 +551,17 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
|
||||
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
|
||||
}, [
|
||||
t,
|
||||
isMobile,
|
||||
dnsEnabled,
|
||||
dns,
|
||||
hostsList,
|
||||
dnsServers,
|
||||
fakeDnsList,
|
||||
draggedIndex,
|
||||
dropTargetIndex,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
|
||||
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
HolderOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
@@ -13,10 +20,16 @@ export type DnsServerRow = { key: number; server: DnsServerValue };
|
||||
export type FakednsTableRow = { key: number; ipPool: string; poolSize: number };
|
||||
|
||||
export function useDnsServerColumns({
|
||||
rowsLength,
|
||||
onHandlePointerDown,
|
||||
openEditServer,
|
||||
moveServer,
|
||||
deleteServer,
|
||||
}: {
|
||||
rowsLength: number;
|
||||
onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void;
|
||||
openEditServer: (idx: number) => void;
|
||||
moveServer: (from: number, to: number) => void;
|
||||
deleteServer: (idx: number) => void;
|
||||
}): ColumnsType<DnsServerRow> {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,6 +42,12 @@ export function useDnsServerColumns({
|
||||
width: 60,
|
||||
render: (_v, _record, index) => (
|
||||
<Space size={6}>
|
||||
<HolderOutlined
|
||||
className="drag-handle"
|
||||
title={t('pages.xray.routing.dragToReorder')}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
|
||||
/>
|
||||
<span className="row-index">{index + 1}</span>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
@@ -43,6 +62,26 @@ export function useDnsServerColumns({
|
||||
),
|
||||
onClick: () => openEditServer(index),
|
||||
},
|
||||
{
|
||||
key: 'up',
|
||||
label: (
|
||||
<>
|
||||
<ArrowUpOutlined aria-hidden /> {t('pages.inbounds.form.moveUp')}
|
||||
</>
|
||||
),
|
||||
disabled: index === 0,
|
||||
onClick: () => moveServer(index, index - 1),
|
||||
},
|
||||
{
|
||||
key: 'down',
|
||||
label: (
|
||||
<>
|
||||
<ArrowDownOutlined aria-hidden /> {t('pages.inbounds.form.moveDown')}
|
||||
</>
|
||||
),
|
||||
disabled: index === rowsLength - 1,
|
||||
onClick: () => moveServer(index, index + 1),
|
||||
},
|
||||
{
|
||||
key: 'del',
|
||||
danger: true,
|
||||
@@ -85,7 +124,7 @@ export function useDnsServerColumns({
|
||||
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
|
||||
},
|
||||
],
|
||||
[t, openEditServer, deleteServer],
|
||||
[t, rowsLength, onHandlePointerDown, openEditServer, moveServer, deleteServer],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
|
||||
import DnsTab from '@/pages/xray/dns/DnsTab';
|
||||
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
import { installPointerEvents, pointAt } from './pointer-events';
|
||||
|
||||
function withHosts(hosts: Record<string, string>): XraySettingsValue {
|
||||
return {
|
||||
@@ -152,3 +153,66 @@ describe('DnsTab', () => {
|
||||
expect(latest().dns).not.toHaveProperty('script');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DnsTab server reorder', () => {
|
||||
beforeEach(installPointerEvents);
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const google = { address: '8.8.8.8', domains: ['geosite:google'] };
|
||||
const rows = () => document.querySelectorAll<HTMLElement>('tr[data-row-key]');
|
||||
const servers = () =>
|
||||
JSON.parse(screen.getByTestId('settings').textContent || '{}').dns.servers as unknown[];
|
||||
|
||||
function openServers() {
|
||||
function Harness() {
|
||||
const [settings, setSettings] = useState<XraySettingsValue | null>({
|
||||
dns: { servers: ['1.1.1.1', google, 'localhost'] },
|
||||
} as unknown as XraySettingsValue);
|
||||
return (
|
||||
<>
|
||||
<DnsTab templateSettings={settings} setTemplateSettings={setSettings} />
|
||||
<output data-testid="settings">{JSON.stringify(settings)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
renderWithProviders(<Harness />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'DNS' }));
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
dir: 'down',
|
||||
from: 0,
|
||||
to: 2,
|
||||
marker: 'drop-after',
|
||||
expected: [google, 'localhost', '1.1.1.1'],
|
||||
},
|
||||
{
|
||||
dir: 'up',
|
||||
from: 2,
|
||||
to: 0,
|
||||
marker: 'drop-before',
|
||||
expected: ['localhost', '1.1.1.1', google],
|
||||
},
|
||||
])('drags a server $dir into the target slot', ({ from, to, marker, expected }) => {
|
||||
openServers();
|
||||
const handle = rows()[from].querySelector('.drag-handle');
|
||||
if (!handle) throw new Error('missing drag handle');
|
||||
pointAt(rows()[to]);
|
||||
const pointer = { pointerId: 7, button: 0, clientX: 10, clientY: 32 };
|
||||
fireEvent.pointerDown(handle, { ...pointer, clientY: 0 });
|
||||
fireEvent.pointerMove(document, pointer);
|
||||
expect(rows()[to].className).toContain(marker);
|
||||
fireEvent.pointerUp(document, pointer);
|
||||
expect(servers()).toEqual(expected);
|
||||
});
|
||||
|
||||
it('moves a server from the row menu and disables Move up on the first row', async () => {
|
||||
openServers();
|
||||
fireEvent.click(within(rows()[0]).getByRole('button', { name: 'more' }));
|
||||
const moveUp = await screen.findByRole('menuitem', { name: /Move up/ });
|
||||
expect(moveUp.getAttribute('aria-disabled')).toBe('true');
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Move down/ }));
|
||||
expect(servers()).toEqual([google, '1.1.1.1', 'localhost']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user