feat(hosts): add drag-and-drop and global subscription ordering (#6895)

Hosts already had one saved order on the Hosts page, but subscriptions honoured
it only inside each inbound, so a host of a later inbound could never come before
one of an earlier inbound (#6894). A new opt-in setting makes raw/base64,
Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds;
the default keeps today's inbound-first output unchanged. The Hosts table gains
drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook
the Routing Rules table now shares.
This commit is contained in:
Hamed
2026-10-24 08:57:47 +03:30
committed by GitHub
parent a69ae2b0ea
commit 136c1c4d50
51 changed files with 1643 additions and 149 deletions
+183
View File
@@ -0,0 +1,183 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HostsPage from '@/pages/hosts/HostsPage';
import { HttpUtil, Msg } from '@/utils';
import { renderWithProviders } from './test-utils';
import { installPointerEvents, pointAt } from './pointer-events';
vi.mock('@/layouts/AppSidebar', () => ({ default: () => null }));
vi.mock('@/pages/hosts/HostFormModal', () => ({ default: () => null }));
const initial = [
{ groupId: 'a', remark: 'Host A', sortOrder: 0, inboundIds: [5], hosts: ['a.example.com'] },
{ groupId: 'b', remark: 'Host B', sortOrder: 1, inboundIds: [20], hosts: ['b.example.com'] },
{ groupId: 'c', remark: 'Host C', sortOrder: 2, inboundIds: [5, 20], hosts: ['c.example.com'] },
];
function rowOrder() {
return [...document.querySelectorAll('tr[data-row-key]')].map((row) =>
row.getAttribute('data-row-key'),
);
}
async function openHosts() {
const view = renderWithProviders(
<MemoryRouter>
<HostsPage />
</MemoryRouter>,
);
await screen.findByText('Host A');
return view;
}
function drag(from: string, to: string, after = false, cancel = false) {
const target = document.querySelector(`tr[data-row-key="${to}"]`);
if (!target) throw new Error('missing drop row');
pointAt(target);
fireEvent.pointerDown(
screen.getByRole('button', { name: `Drag to reorder Host ${from.toUpperCase()}` }),
{ button: 0, pointerId: 1, clientY: 0 },
);
fireEvent.pointerMove(document, { pointerId: 1, clientX: 10, clientY: after ? 48 : 32 });
fireEvent[cancel ? 'pointerCancel' : 'pointerUp'](document, {
pointerId: 1,
clientX: 10,
clientY: after ? 48 : 32,
});
}
describe('Hosts page reordering', () => {
afterEach(() => vi.unstubAllGlobals());
beforeEach(() => {
installPointerEvents();
let stored = initial.map((h) => ({ ...h }));
vi.spyOn(HttpUtil, 'get').mockImplementation(
async (url) => new Msg(true, '', url.includes('/hosts/list') ? stored : []),
);
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, body) => {
if (url === '/panel/api/hosts/reorder') {
const ids = (body as { ids: string[] }).ids;
stored = ids.map((id, sortOrder) => ({
...stored.find((h) => h.groupId === id)!,
sortOrder,
}));
}
return new Msg(true, '', {});
});
});
it('persists one cross-inbound reorder on drop and reloads the saved order', async () => {
await openHosts();
drag('b', 'a');
await waitFor(() =>
expect(HttpUtil.post).toHaveBeenCalledExactlyOnceWith(
'/panel/api/hosts/reorder',
{ ids: ['b', 'a', 'c'] },
{ headers: { 'Content-Type': 'application/json' } },
),
);
await waitFor(() => expect(rowOrder()).toEqual(['b', 'a', 'c']));
});
it('does not persist a no-op drop or a cancelled pointer', async () => {
await openHosts();
drag('a', 'a');
drag('a', 'b');
drag('b', 'a', true);
drag('b', 'a', false, true);
expect(HttpUtil.post).not.toHaveBeenCalled();
expect(rowOrder()).toEqual(['a', 'b', 'c']);
});
it.each([
[false, ['b', 'a', 'c']],
[true, ['b', 'c', 'a']],
])('inserts before/after the target (after=%s)', async (after, expected) => {
await openHosts();
drag('a', 'c', after);
await waitFor(() => expect(rowOrder()).toEqual(expected));
await waitFor(() =>
expect(HttpUtil.post).toHaveBeenCalledExactlyOnceWith(
'/panel/api/hosts/reorder',
{ ids: expected },
{ headers: { 'Content-Type': 'application/json' } },
),
);
});
it('persists a touch drag in the mobile RTL table', async () => {
const nativeMatchMedia = window.matchMedia;
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...nativeMatchMedia(query),
matches: query === '(max-width: 768px)',
}));
const { container } = await openHosts();
container.dir = 'rtl';
pointAt(document.querySelector('tr[data-row-key="a"]'));
const event = { pointerId: 4, button: 0, pointerType: 'touch', clientX: 10, clientY: 32 };
fireEvent.pointerDown(screen.getByRole('button', { name: 'Drag to reorder Host B' }), {
...event,
clientY: 0,
});
fireEvent.pointerMove(document, event);
fireEvent.pointerUp(document, event);
await waitFor(() => expect(rowOrder()).toEqual(['b', 'a', 'c']));
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(1));
});
it('cancels an active drag on unmount', async () => {
const view = await openHosts();
fireEvent.pointerDown(screen.getByRole('button', { name: 'Drag to reorder Host A' }), {
pointerId: 1,
button: 0,
clientY: 0,
});
view.unmount();
fireEvent.pointerMove(document, { pointerId: 1, clientY: 40, clientX: 10 });
fireEvent.pointerUp(document, { pointerId: 1 });
expect(HttpUtil.post).not.toHaveBeenCalled();
});
it('restores the visible order after failed persistence', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'Cannot save order'));
await openHosts();
drag('a', 'c', true);
await screen.findByText('Cannot save order');
expect(rowOrder()).toEqual(['a', 'b', 'c']);
});
it('prevents overlapping reorders while a save is pending', async () => {
let finish: ((result: Msg<unknown>) => void) | undefined;
vi.spyOn(HttpUtil, 'post').mockImplementation(
() =>
new Promise<Msg<unknown>>((resolve) => {
finish = resolve;
}),
);
await openHosts();
drag('b', 'a');
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(1));
expect(
(screen.getByRole('button', { name: 'Drag to reorder Host C' }) as HTMLButtonElement)
.disabled,
).toBe(true);
drag('c', 'a');
expect(HttpUtil.post).toHaveBeenCalledTimes(1);
await act(async () => {
finish?.(new Msg(false, 'Cannot save order'));
});
expect(rowOrder()).toEqual(['a', 'b', 'c']);
});
it('keeps Up/Down controls and row actions separate from dragging', async () => {
await openHosts();
fireEvent.click(screen.getAllByRole('button', { name: 'Move down' })[0]);
await waitFor(() => expect(rowOrder()).toEqual(['b', 'a', 'c']));
expect(HttpUtil.post).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]);
fireEvent.click(screen.getAllByRole('checkbox')[1]);
expect(HttpUtil.post).toHaveBeenCalledTimes(1);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { vi } from 'vitest';
export function installPointerEvents() {
class BrowserPointerEvent extends MouseEvent {
pointerId: number;
isPrimary: boolean;
pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
this.isPrimary = init.isPrimary ?? true;
this.pointerType = init.pointerType ?? 'mouse';
}
}
vi.stubGlobal('PointerEvent', BrowserPointerEvent);
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => null });
}
export function pointAt(row: Element | null) {
vi.spyOn(document, 'elementFromPoint').mockReturnValue(row);
if (row)
vi.spyOn(row, 'getBoundingClientRect').mockReturnValue({ top: 30, height: 20 } as DOMRect);
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import RoutingTab from '@/pages/xray/routing/RoutingTab';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
import { installPointerEvents, pointAt } from './pointer-events';
const initial = {
routing: {
domainStrategy: 'AsIs',
rules: [
{ type: 'field', outboundTag: 'a', enabled: true },
{ type: 'field', inboundTag: ['_bl_bal1'], outboundTag: 'hidden', enabled: true },
{ type: 'field', outboundTag: 'b', enabled: true },
{ type: 'field', outboundTag: 'c', enabled: true },
],
},
} as unknown as XraySettingsValue;
function Harness({ mobile }: { mobile: boolean }) {
const [settings, setSettings] = useState<XraySettingsValue | null>(initial);
return (
<>
<RoutingTab
templateSettings={settings}
setTemplateSettings={setSettings}
inboundTags={[]}
clientReverseTags={[]}
isMobile={mobile}
/>
<output data-testid="settings">{JSON.stringify(settings)}</output>
</>
);
}
function ruleOrder() {
const settings = JSON.parse(
screen.getByTestId('settings').textContent || '{}',
) as XraySettingsValue;
return settings.routing?.rules?.map((rule) => rule.outboundTag);
}
function openRouting(mobile: boolean) {
const view = renderWithProviders(<Harness mobile={mobile} />);
fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ }));
return {
...view,
rows: () =>
document.querySelectorAll(mobile ? '.rule-card' : '.routing-table tr[data-row-key]'),
};
}
function gesture(
rows: NodeListOf<Element>,
from: number,
to: number,
cancel = false,
touch = false,
) {
pointAt(rows[to]);
const event = { pointerId: 7, button: 0, pointerType: touch ? 'touch' : 'mouse' };
fireEvent.pointerDown(rows[from].querySelector('.drag-handle')!, { ...event, clientY: 0 });
fireEvent.pointerMove(document, { ...event, clientY: 32, clientX: 10 });
fireEvent[cancel ? 'pointerCancel' : 'pointerUp'](document, {
...event,
clientY: 32,
clientX: 10,
});
}
describe.each([false, true])('Routing reorder (mobile=%s)', (mobile) => {
beforeEach(installPointerEvents);
afterEach(() => vi.unstubAllGlobals());
it('maps visible positions through hidden loopback rules when moving upward', () => {
const view = openRouting(mobile);
expect(view.rows()).toHaveLength(3);
gesture(view.rows(), 1, 0, false, mobile);
expect(ruleOrder()).toEqual(['b', 'a', 'hidden', 'c']);
});
it('retains the old splice destination when moving downward', () => {
const view = openRouting(mobile);
gesture(view.rows(), 0, 2, false, mobile);
expect(ruleOrder()).toEqual(['hidden', 'b', 'c', 'a']);
});
it('maps a drop target after a hidden loopback back to the intended rule', () => {
const view = openRouting(mobile);
gesture(view.rows(), 0, 1, false, mobile);
expect(ruleOrder()).toEqual(['hidden', 'b', 'a', 'c']);
});
it('never moves rules when the active pointer is cancelled', () => {
const view = openRouting(mobile);
gesture(view.rows(), 1, 0, true, mobile);
expect(ruleOrder()).toEqual(['a', 'hidden', 'b', 'c']);
});
});
@@ -37,6 +37,27 @@ function LocationProbe() {
}
describe('SubscriptionGeneralTab', () => {
it('selects and reloads the saved subscription node ordering mode', () => {
const updateSetting = vi.fn();
const { unmount } = renderWithProviders(
<MemoryRouter>
<SubscriptionGeneralTab allSetting={new AllSetting()} updateSetting={updateSetting} />
</MemoryRouter>,
);
expect(screen.getByText('Inbound order (default)')).toBeTruthy();
chooseSelectOption('sub-host-sort-mode', 'Hosts order');
expect(updateSetting).toHaveBeenCalledWith({ subHostSortMode: 'hosts-first' });
unmount();
renderWithProviders(
<MemoryRouter>
<SubscriptionGeneralTab
allSetting={new AllSetting({ subHostSortMode: 'hosts-first' })}
updateSetting={updateSetting}
/>
</MemoryRouter>,
);
expect(screen.getByText('Hosts order')).toBeTruthy();
});
it('switches profile modes without losing the custom URL and warns only for the built-in page', () => {
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
const editedUrl = 'https://example.com/account/{{SUB_ID}}';
@@ -13,6 +13,30 @@ afterEach(() => {
});
describe('useAllSettings', () => {
it('persists the subscription host ordering mode and reloads it in a fresh session', async () => {
let storedMode = 'inbound-first';
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url, body) => {
if (url === '/panel/api/setting/all')
return new Msg(true, '', { subHostSortMode: storedMode });
if (url === '/panel/api/setting/update')
storedMode = (body as { subHostSortMode: string }).subHostSortMode;
return new Msg(true, '', {});
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>
);
const first = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(first.result.current.fetched).toBe(true));
expect(first.result.current.allSetting.subHostSortMode).toBe('inbound-first');
act(() => first.result.current.updateSetting({ subHostSortMode: 'hosts-first' }));
await act(async () => {
await first.result.current.saveAll();
});
first.unmount();
const second = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(second.result.current.fetched).toBe(true));
expect(second.result.current.allSetting.subHostSortMode).toBe('hosts-first');
});
it('refreshes cached default settings after a successful save', async () => {
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', {}));
const queryClient = makeTestQueryClient();
@@ -0,0 +1,238 @@
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePointerRowReorder } from '@/hooks/usePointerRowReorder';
import type { PointerRowDrop } from '@/hooks/usePointerRowReorder';
import { installPointerEvents, pointAt } from './pointer-events';
type Key = string | number;
function Harness({
onDrop,
disabled = false,
keys = ['a', 'b'],
}: {
onDrop: (drop: PointerRowDrop<Key>) => void;
disabled?: boolean;
keys?: Key[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const { drag, onPointerDown } = usePointerRowReorder({
containerRef,
getRowKey: (row) => keys.find((key) => String(key) === row.dataset.rowKey) ?? null,
disabled,
onDrop,
});
return (
<>
<div ref={containerRef}>
{keys.map((key) => (
<div key={key} data-row-key={key} data-testid={`row-${key}`}>
<button onPointerDown={(event) => onPointerDown(key, event)}>{key}</button>
</div>
))}
</div>
<div data-row-key="b" data-testid="outside" />
<output data-testid="drag">{drag ? JSON.stringify(drag) : 'idle'}</output>
</>
);
}
const pointer = { pointerId: 7, button: 0, clientX: 10, clientY: 32 };
const dragState = () => screen.getByTestId('drag').textContent;
function start(key: Key = 'a', init: PointerEventInit = {}) {
fireEvent.pointerDown(screen.getByRole('button', { name: String(key) }), {
...pointer,
clientY: 30,
...init,
});
}
describe('usePointerRowReorder', () => {
beforeEach(installPointerEvents);
afterEach(() => vi.unstubAllGlobals());
it('activates at five vertical pixels and stays active when returning near the origin', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 34 });
expect(dragState()).toBe('idle');
fireEvent.pointerMove(document, { ...pointer, clientY: 35 });
expect(dragState()).toBe('{"from":"a","to":"b","after":false}');
fireEvent.pointerMove(document, { ...pointer, clientY: 31 });
expect(dragState()).toBe('{"from":"a","to":"b","after":false}');
fireEvent.pointerUp(document, pointer);
expect(onDrop).toHaveBeenCalledExactlyOnceWith({ from: 'a', to: 'b', after: false });
});
it.each([{ button: 2 }, { isPrimary: false }])('rejects invalid pointers %j', (init) => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start('a', init);
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, pointer);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('ignores move, up and cancel from other pointers', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, pointerId: 8, clientY: 48 });
expect(dragState()).toBe('idle');
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, { ...pointer, pointerId: 8 });
fireEvent.pointerCancel(document, { ...pointer, pointerId: 8 });
expect(onDrop).not.toHaveBeenCalled();
expect(dragState()).toBe('{"from":"a","to":"b","after":true}');
fireEvent.pointerUp(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, { ...pointer, clientY: 48 });
expect(onDrop).toHaveBeenCalledExactlyOnceWith({ from: 'a', to: 'b', after: true });
});
it('cancels without committing and safely releases pointer capture', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
const handle = screen.getByRole('button', { name: 'a' });
handle.setPointerCapture = vi.fn();
handle.hasPointerCapture = vi.fn(() => true);
handle.releasePointerCapture = vi.fn();
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerCancel(document, pointer);
fireEvent.pointerUp(document, pointer);
expect(handle.setPointerCapture).toHaveBeenCalledWith(7);
expect(handle.releasePointerCapture).toHaveBeenCalledWith(7);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('does not reuse a previous valid target when released outside the container', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
pointAt(screen.getByTestId('outside'));
fireEvent.pointerMove(document, { ...pointer, clientY: 50 });
expect(dragState()).toBe('{"from":"a","to":null,"after":false}');
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
expect(dragState()).toBe('idle');
});
it('rechecks the release target even when no final move event arrived', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
pointAt(null);
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
});
it('cancels when pointer capture is lost', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.lostPointerCapture(screen.getByRole('button', { name: 'a' }), pointer);
fireEvent.pointerUp(document, pointer);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('does not drop on the source row or on a click below threshold', () => {
const onDrop = vi.fn();
render(<Harness onDrop={onDrop} />);
pointAt(screen.getByTestId('row-a'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, pointer);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
});
it('uses the latest committed callback during a drag', () => {
const oldDrop = vi.fn();
const newDrop = vi.fn();
const view = render(<Harness onDrop={oldDrop} />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
view.rerender(<Harness onDrop={newDrop} />);
fireEvent.pointerUp(document, pointer);
expect(oldDrop).not.toHaveBeenCalled();
expect(newDrop).toHaveBeenCalledExactlyOnceWith({ from: 'a', to: 'b', after: false });
});
it('blocks disabled gestures and cancels when disabled during a drag', () => {
const onDrop = vi.fn();
const view = render(<Harness onDrop={onDrop} disabled />);
pointAt(screen.getByTestId('row-b'));
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
expect(dragState()).toBe('idle');
view.rerender(<Harness onDrop={onDrop} />);
start();
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
view.rerender(<Harness onDrop={onDrop} disabled />);
fireEvent.pointerUp(document, pointer);
expect(dragState()).toBe('idle');
expect(onDrop).not.toHaveBeenCalled();
});
it('removes all gesture listeners and releases capture on unmount', () => {
const onDrop = vi.fn();
const view = render(<Harness onDrop={onDrop} />);
const handle = screen.getByRole('button', { name: 'a' });
handle.setPointerCapture = vi.fn();
handle.hasPointerCapture = vi.fn(() => true);
handle.releasePointerCapture = vi.fn();
const add = vi.spyOn(document, 'addEventListener');
const remove = vi.spyOn(document, 'removeEventListener');
const removeCapture = vi.spyOn(handle, 'removeEventListener');
start();
const registrations = add.mock.calls.filter(([type]) =>
['pointermove', 'pointerup', 'pointercancel'].includes(type),
);
expect(registrations.map(([type]) => type).sort()).toEqual([
'pointercancel',
'pointermove',
'pointerup',
]);
view.unmount();
for (const [type, listener] of registrations)
expect(remove).toHaveBeenCalledWith(type, listener);
expect(removeCapture).toHaveBeenCalledWith('lostpointercapture', expect.any(Function));
expect(handle.releasePointerCapture).toHaveBeenCalledWith(7);
fireEvent.pointerUp(document, pointer);
expect(onDrop).not.toHaveBeenCalled();
});
it('supports numeric keys and touch when capture APIs throw', () => {
const onDrop = vi.fn();
render(<Harness keys={[0, 1]} onDrop={onDrop} />);
const handle = screen.getByRole('button', { name: '0' });
handle.setPointerCapture = () => {
throw new DOMException('inactive pointer');
};
pointAt(screen.getByTestId('row-1'));
start(0, { pointerType: 'touch' });
fireEvent.pointerMove(document, { ...pointer, clientY: 48 });
fireEvent.pointerUp(document, pointer);
expect(onDrop).toHaveBeenCalledExactlyOnceWith({ from: 0, to: 1, after: false });
});
});