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:
Cheng Ho Ming, Eric
2026-11-14 00:25:38 +08:00
committed by GitHub
parent 166d5633cc
commit 51f7ba47f7
3 changed files with 161 additions and 14 deletions
+54 -10
View File
@@ -27,6 +27,7 @@ import { selectTagRender } from '@/components/form';
import { onNumber } from '@/utils/onNumber'; import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { usePointerRowReorder } from '@/hooks/usePointerRowReorder';
import { catTabLabel } from '@/pages/settings/catTabLabel'; import { catTabLabel } from '@/pages/settings/catTabLabel';
import DnsServerModal from './DnsServerModal'; import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } 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 [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [presetsModalOpen, setPresetsModalOpen] = useState(false); const [presetsModalOpen, setPresetsModalOpen] = useState(false);
const serversContainerRef = useRef<HTMLDivElement>(null);
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null; const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns; const dnsEnabled = !!dns;
@@ -166,8 +168,33 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
}, },
[mutate], [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() { function openAddServer() {
setEditingServer(null); setEditingServer(null);
@@ -475,14 +502,21 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
{t('pages.xray.dns.clearAll')} {t('pages.xray.dns.clearAll')}
</Button> </Button>
</Space> </Space>
<Table <div ref={serversContainerRef}>
columns={dnsColumns} <Table
dataSource={dnsServers} columns={dnsColumns}
rowKey={(r) => r.key} dataSource={dnsServers}
pagination={false} rowKey={(r) => r.key}
size="small" rowClassName={(_r, i) => {
bordered 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> </Space>
), ),
}); });
@@ -517,7 +551,17 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
return out; return out;
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 ( return (
<> <>
+41 -2
View File
@@ -1,7 +1,14 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Input, InputNumber, Space } from 'antd'; 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 type { ColumnsType } from 'antd/es/table';
import { onNumber } from '@/utils/onNumber'; 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 type FakednsTableRow = { key: number; ipPool: string; poolSize: number };
export function useDnsServerColumns({ export function useDnsServerColumns({
rowsLength,
onHandlePointerDown,
openEditServer, openEditServer,
moveServer,
deleteServer, deleteServer,
}: { }: {
rowsLength: number;
onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void;
openEditServer: (idx: number) => void; openEditServer: (idx: number) => void;
moveServer: (from: number, to: number) => void;
deleteServer: (idx: number) => void; deleteServer: (idx: number) => void;
}): ColumnsType<DnsServerRow> { }): ColumnsType<DnsServerRow> {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -29,6 +42,12 @@ export function useDnsServerColumns({
width: 60, width: 60,
render: (_v, _record, index) => ( render: (_v, _record, index) => (
<Space size={6}> <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> <span className="row-index">{index + 1}</span>
<Dropdown <Dropdown
trigger={['click']} trigger={['click']}
@@ -43,6 +62,26 @@ export function useDnsServerColumns({
), ),
onClick: () => openEditServer(index), 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', key: 'del',
danger: true, danger: true,
@@ -85,7 +124,7 @@ export function useDnsServerColumns({
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>, render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
}, },
], ],
[t, openEditServer, deleteServer], [t, rowsLength, onHandlePointerDown, openEditServer, moveServer, deleteServer],
); );
} }
+66 -2
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, screen } from '@testing-library/react'; import { fireEvent, screen, within } from '@testing-library/react';
import DnsTab from '@/pages/xray/dns/DnsTab'; import DnsTab from '@/pages/xray/dns/DnsTab';
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting'; import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils'; import { renderWithProviders } from './test-utils';
import { installPointerEvents, pointAt } from './pointer-events';
function withHosts(hosts: Record<string, string>): XraySettingsValue { function withHosts(hosts: Record<string, string>): XraySettingsValue {
return { return {
@@ -152,3 +153,66 @@ describe('DnsTab', () => {
expect(latest().dns).not.toHaveProperty('script'); 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']);
});
});