mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 23:35:00 +03:00
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:
@@ -350,6 +350,10 @@
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHostSortMode": {
|
||||
"example": "inbound-first",
|
||||
"type": "string"
|
||||
},
|
||||
"subIncyAnnounceUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -705,6 +709,7 @@
|
||||
"subHappTunMode",
|
||||
"subHappTunType",
|
||||
"subHideSettings",
|
||||
"subHostSortMode",
|
||||
"subIncyAnnounceUrl",
|
||||
"subIncyAppAutoDetect",
|
||||
"subIncyBannerBgColor",
|
||||
@@ -1136,6 +1141,10 @@
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHostSortMode": {
|
||||
"example": "inbound-first",
|
||||
"type": "string"
|
||||
},
|
||||
"subIncyAnnounceUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1499,6 +1508,7 @@
|
||||
"subHappTunMode",
|
||||
"subHappTunType",
|
||||
"subHideSettings",
|
||||
"subHostSortMode",
|
||||
"subIncyAnnounceUrl",
|
||||
"subIncyAppAutoDetect",
|
||||
"subIncyBannerBgColor",
|
||||
@@ -12619,7 +12629,7 @@
|
||||
"tags": [
|
||||
"Hosts"
|
||||
],
|
||||
"summary": "Set host group sort order by the position of each groupId in the array.",
|
||||
"summary": "Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.",
|
||||
"operationId": "post_panel_api_hosts_reorder",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useHostMutations() {
|
||||
mutationFn: (groupIds: string[]) =>
|
||||
HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
if (msg?.success) return invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHappTunMode": "",
|
||||
"subHappTunType": "",
|
||||
"subHideSettings": false,
|
||||
"subHostSortMode": "inbound-first",
|
||||
"subIncyAnnounceUrl": "",
|
||||
"subIncyAppAutoDetect": false,
|
||||
"subIncyBannerBgColor": "",
|
||||
@@ -289,6 +290,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHappTunMode": "",
|
||||
"subHappTunType": "",
|
||||
"subHideSettings": false,
|
||||
"subHostSortMode": "inbound-first",
|
||||
"subIncyAnnounceUrl": "",
|
||||
"subIncyAppAutoDetect": false,
|
||||
"subIncyBannerBgColor": "",
|
||||
|
||||
@@ -324,6 +324,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHostSortMode": {
|
||||
"example": "inbound-first",
|
||||
"type": "string"
|
||||
},
|
||||
"subIncyAnnounceUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -679,6 +683,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappTunMode",
|
||||
"subHappTunType",
|
||||
"subHideSettings",
|
||||
"subHostSortMode",
|
||||
"subIncyAnnounceUrl",
|
||||
"subIncyAppAutoDetect",
|
||||
"subIncyBannerBgColor",
|
||||
@@ -1110,6 +1115,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHostSortMode": {
|
||||
"example": "inbound-first",
|
||||
"type": "string"
|
||||
},
|
||||
"subIncyAnnounceUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1473,6 +1482,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappTunMode",
|
||||
"subHappTunType",
|
||||
"subHideSettings",
|
||||
"subHostSortMode",
|
||||
"subIncyAnnounceUrl",
|
||||
"subIncyAppAutoDetect",
|
||||
"subIncyBannerBgColor",
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface AllSetting {
|
||||
subHappTunMode: string;
|
||||
subHappTunType: string;
|
||||
subHideSettings: boolean;
|
||||
subHostSortMode: string;
|
||||
subIncyAnnounceUrl: string;
|
||||
subIncyAppAutoDetect: boolean;
|
||||
subIncyBannerBgColor: string;
|
||||
@@ -298,6 +299,7 @@ export interface AllSettingView {
|
||||
subHappTunMode: string;
|
||||
subHappTunType: string;
|
||||
subHideSettings: boolean;
|
||||
subHostSortMode: string;
|
||||
subIncyAnnounceUrl: string;
|
||||
subIncyAppAutoDetect: boolean;
|
||||
subIncyBannerBgColor: string;
|
||||
|
||||
@@ -124,6 +124,7 @@ export const AllSettingSchema = z.object({
|
||||
subHappTunMode: z.string(),
|
||||
subHappTunType: z.string(),
|
||||
subHideSettings: z.boolean(),
|
||||
subHostSortMode: z.string(),
|
||||
subIncyAnnounceUrl: z.string(),
|
||||
subIncyAppAutoDetect: z.boolean(),
|
||||
subIncyBannerBgColor: z.string(),
|
||||
@@ -315,6 +316,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subHappTunMode: z.string(),
|
||||
subHappTunType: z.string(),
|
||||
subHideSettings: z.boolean(),
|
||||
subHostSortMode: z.string(),
|
||||
subIncyAnnounceUrl: z.string(),
|
||||
subIncyAppAutoDetect: z.boolean(),
|
||||
subIncyBannerBgColor: z.string(),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
||||
|
||||
export interface PointerRowDrop<TKey extends string | number> {
|
||||
from: TKey;
|
||||
to: TKey;
|
||||
after: boolean;
|
||||
}
|
||||
|
||||
interface PointerRowDrag<TKey extends string | number> {
|
||||
from: TKey;
|
||||
to: TKey | null;
|
||||
after: boolean;
|
||||
}
|
||||
|
||||
interface PointerRowReorderOptions<TKey extends string | number> {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
getRowKey: (row: HTMLElement) => TKey | null;
|
||||
disabled?: boolean;
|
||||
isNoop?: (drop: PointerRowDrop<TKey>) => boolean;
|
||||
onDrop: (drop: PointerRowDrop<TKey>) => void;
|
||||
}
|
||||
|
||||
// Rows expose data-row-key; consumers retain their own insertion and persistence rules.
|
||||
export function usePointerRowReorder<TKey extends string | number>(
|
||||
options: PointerRowReorderOptions<TKey>,
|
||||
) {
|
||||
const [drag, setDrag] = useState<PointerRowDrag<TKey> | null>(null);
|
||||
const latest = useRef(options);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
latest.current = options;
|
||||
if (cleanupRef.current && options.disabled) {
|
||||
cleanupRef.current();
|
||||
setDrag(null);
|
||||
}
|
||||
}, [options]);
|
||||
useLayoutEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
const onPointerDown = useCallback((from: TKey, event: ReactPointerEvent<Element>) => {
|
||||
const current = latest.current;
|
||||
if (current.disabled || cleanupRef.current || event.isPrimary === false || event.button !== 0)
|
||||
return;
|
||||
event.preventDefault();
|
||||
const handle = event.currentTarget;
|
||||
const doc = handle.ownerDocument;
|
||||
const pointerId = event.pointerId;
|
||||
const startY = event.clientY;
|
||||
let active = false;
|
||||
|
||||
const targetAt = (e: PointerEvent): PointerRowDrop<TKey> | null => {
|
||||
const row = doc
|
||||
.elementFromPoint(e.clientX, e.clientY)
|
||||
?.closest<HTMLElement>('[data-row-key]');
|
||||
const config = latest.current;
|
||||
if (!row || !config.containerRef.current?.contains(row)) return null;
|
||||
const to = config.getRowKey(row);
|
||||
if (to === null) return null;
|
||||
const rect = row.getBoundingClientRect();
|
||||
return { from, to, after: e.clientY > rect.top + rect.height / 2 };
|
||||
};
|
||||
const move = (e: PointerEvent) => {
|
||||
if (e.pointerId !== pointerId || (!active && Math.abs(e.clientY - startY) < 5)) return;
|
||||
active = true;
|
||||
setDrag(targetAt(e) ?? { from, to: null, after: false });
|
||||
};
|
||||
const cleanup = () => {
|
||||
doc.removeEventListener('pointermove', move);
|
||||
doc.removeEventListener('pointerup', up);
|
||||
doc.removeEventListener('pointercancel', cancel);
|
||||
handle.removeEventListener('lostpointercapture', cancel);
|
||||
cleanupRef.current = null;
|
||||
try {
|
||||
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// Capture may already be released when the handle is detached.
|
||||
}
|
||||
};
|
||||
const cancel = (e: Event) => {
|
||||
if (!('pointerId' in e) || e.pointerId !== pointerId) return;
|
||||
cleanup();
|
||||
setDrag(null);
|
||||
};
|
||||
const up = (e: PointerEvent) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const drop =
|
||||
active && latest.current.containerRef.current?.contains(handle) ? targetAt(e) : null;
|
||||
cleanup();
|
||||
setDrag(null);
|
||||
const config = latest.current;
|
||||
if (!drop || config.disabled || drop.from === drop.to || config.isNoop?.(drop)) return;
|
||||
config.onDrop(drop);
|
||||
};
|
||||
|
||||
cleanupRef.current = cleanup;
|
||||
doc.addEventListener('pointermove', move);
|
||||
doc.addEventListener('pointerup', up);
|
||||
doc.addEventListener('pointercancel', cancel);
|
||||
handle.addEventListener('lostpointercapture', cancel);
|
||||
try {
|
||||
handle.setPointerCapture?.(pointerId);
|
||||
} catch {
|
||||
// Detached handles and synthetic pointers may not support capture.
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { drag, onPointerDown };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ObjectUtil } from '@/utils';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
import type { SubProfileMode, SubHostSortMode } from '@/schemas/setting';
|
||||
|
||||
export class AllSetting {
|
||||
webListen = '';
|
||||
@@ -20,6 +20,7 @@ export class AllSetting {
|
||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||
subShowIdentityOnAllLinks = false;
|
||||
subInfoNodeEnable = false;
|
||||
subHostSortMode: SubHostSortMode = 'inbound-first';
|
||||
subCalendarExpireInclusive = false;
|
||||
subExpiredTemplate = '⛔ {{EMAIL}} | Expired: {{EXPIRE_DATE}}';
|
||||
subTrafficDepletedTemplate =
|
||||
|
||||
@@ -1893,7 +1893,8 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/hosts/reorder',
|
||||
summary: 'Set host group sort order by the position of each groupId in the array.',
|
||||
summary:
|
||||
'Set host group sort order by the position of each groupId in the array. IDs must be unique and refer to existing groups; omitted groups follow in their previous order.',
|
||||
body: '{\n "ids": ["abc-123", "def-456"]\n}',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.host-drag-handle {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.host-drag-handle:focus-visible {
|
||||
outline: 2px solid var(--ant-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.host-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.host-drop-before > td {
|
||||
border-top: 2px solid var(--ant-color-primary) !important;
|
||||
}
|
||||
.host-drop-after > td {
|
||||
border-bottom: 2px solid var(--ant-color-primary) !important;
|
||||
}
|
||||
|
||||
.host-remark-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
HolderOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { usePointerRowReorder, type PointerRowDrop } from '@/hooks/usePointerRowReorder';
|
||||
import './HostList.css';
|
||||
|
||||
interface HostListProps {
|
||||
@@ -27,6 +29,8 @@ interface HostListProps {
|
||||
onDelete: (host: HostRecord) => void;
|
||||
onToggleEnable: (host: HostRecord, next: boolean) => void;
|
||||
onMove: (host: HostRecord, dir: 'up' | 'down') => void;
|
||||
onReorder: (groupIds: string[]) => void;
|
||||
reordering?: boolean;
|
||||
onBulkEnable: (enable: boolean) => void;
|
||||
onBulkDelete: () => void;
|
||||
}
|
||||
@@ -52,10 +56,19 @@ export function sortHosts(hosts: HostRecord[]): HostRecord[] {
|
||||
const sa = a.sortOrder ?? 0;
|
||||
const sb = b.sortOrder ?? 0;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (a.remark || '').localeCompare(b.remark || '');
|
||||
const ar = a.remark || '';
|
||||
const br = b.remark || '';
|
||||
if (ar !== br) return ar < br ? -1 : 1;
|
||||
return a.groupId < b.groupId ? -1 : a.groupId > b.groupId ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hostDropDestination(keys: string[], { from, to, after }: PointerRowDrop<string>) {
|
||||
const source = keys.indexOf(from);
|
||||
const slot = keys.indexOf(to) + (after ? 1 : 0);
|
||||
return slot - (source < slot ? 1 : 0);
|
||||
}
|
||||
|
||||
export default function HostList(props: HostListProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -70,6 +83,8 @@ export default function HostList(props: HostListProps) {
|
||||
onDelete,
|
||||
onToggleEnable,
|
||||
onMove,
|
||||
onReorder,
|
||||
reordering,
|
||||
onBulkEnable,
|
||||
onBulkDelete,
|
||||
} = props;
|
||||
@@ -81,23 +96,47 @@ export default function HostList(props: HostListProps) {
|
||||
}, [inboundOptions]);
|
||||
|
||||
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const keys = sorted.map((h) => h.groupId);
|
||||
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
|
||||
containerRef: tableRef,
|
||||
getRowKey: (row) => keys.find((key) => key === row.dataset.rowKey) ?? null,
|
||||
disabled: reordering || loading,
|
||||
isNoop: (drop) => keys.indexOf(drop.from) === hostDropDestination(keys, drop),
|
||||
onDrop: (drop) => {
|
||||
const ids = [...keys];
|
||||
const destination = hostDropDestination(keys, drop);
|
||||
ids.splice(ids.indexOf(drop.from), 1);
|
||||
ids.splice(destination, 0, drop.from);
|
||||
onReorder(ids);
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnsType<HostRecord> = [
|
||||
{
|
||||
title: t('pages.hosts.fields.actions'),
|
||||
key: 'actions',
|
||||
width: 168,
|
||||
width: 196,
|
||||
render: (_, h, idx) => {
|
||||
const count = sorted.length;
|
||||
return (
|
||||
<Space size={2}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="host-drag-handle"
|
||||
icon={<HolderOutlined aria-hidden />}
|
||||
aria-label={t('pages.hosts.dragToReorder', { name: h.remark || h.groupId })}
|
||||
disabled={reordering || loading}
|
||||
onPointerDown={(event) => onHandlePointerDown(h.groupId, event)}
|
||||
/>
|
||||
<Tooltip title={t('pages.hosts.moveUp')}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined aria-hidden />}
|
||||
aria-label={t('pages.hosts.moveUp')}
|
||||
disabled={idx === 0}
|
||||
disabled={reordering || loading || idx === 0}
|
||||
onClick={() => onMove(h, 'up')}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -107,7 +146,7 @@ export default function HostList(props: HostListProps) {
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined aria-hidden />}
|
||||
aria-label={t('pages.hosts.moveDown')}
|
||||
disabled={idx >= count - 1}
|
||||
disabled={reordering || loading || idx >= count - 1}
|
||||
onClick={() => onMove(h, 'down')}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -305,7 +344,7 @@ export default function HostList(props: HostListProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="small" hoverable title={toolbar} className="hosts-card">
|
||||
<Card size="small" hoverable title={toolbar} className="hosts-card" ref={tableRef}>
|
||||
<Table<HostRecord>
|
||||
rowKey="groupId"
|
||||
size="small"
|
||||
@@ -313,6 +352,12 @@ export default function HostList(props: HostListProps) {
|
||||
columns={columns}
|
||||
dataSource={sorted}
|
||||
pagination={false}
|
||||
rowClassName={(h) =>
|
||||
[
|
||||
drag?.from === h.groupId ? 'host-dragging' : '',
|
||||
drag?.to === h.groupId ? (drag.after ? 'host-drop-after' : 'host-drop-before') : '',
|
||||
].join(' ')
|
||||
}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedGroupIds,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -45,6 +45,35 @@ export default function HostsPage() {
|
||||
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
|
||||
const [formHost, setFormHost] = useState<HostRecord | null>(null);
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
|
||||
const [pendingOrder, setPendingOrder] = useState<string[] | null>(null);
|
||||
const savingOrder = useRef(false);
|
||||
const visibleHosts = useMemo(
|
||||
() =>
|
||||
pendingOrder
|
||||
? hosts.map((h) => ({ ...h, sortOrder: pendingOrder.indexOf(h.groupId) }))
|
||||
: hosts,
|
||||
[hosts, pendingOrder],
|
||||
);
|
||||
|
||||
const onReorder = useCallback(
|
||||
async (groupIds: string[]) => {
|
||||
const current = sortHosts(hosts).map((h) => h.groupId);
|
||||
if (savingOrder.current || current.every((id, i) => id === groupIds[i])) return;
|
||||
savingOrder.current = true;
|
||||
setPendingOrder(groupIds);
|
||||
try {
|
||||
const msg = await reorder(groupIds);
|
||||
if (msg?.success) messageApi.success(t('pages.hosts.toasts.update'));
|
||||
else messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
} catch {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
savingOrder.current = false;
|
||||
setPendingOrder(null);
|
||||
}
|
||||
},
|
||||
[hosts, reorder, messageApi, t],
|
||||
);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
setFormMode('add');
|
||||
@@ -99,9 +128,9 @@ export default function HostsPage() {
|
||||
if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
|
||||
const groupIds = sorted.map((h) => h.groupId);
|
||||
[groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
|
||||
await reorder(groupIds);
|
||||
await onReorder(groupIds);
|
||||
},
|
||||
[hosts, reorder],
|
||||
[hosts, onReorder],
|
||||
);
|
||||
|
||||
const onBulkEnable = useCallback(
|
||||
@@ -207,7 +236,7 @@ export default function HostsPage() {
|
||||
|
||||
<Col span={24}>
|
||||
<HostList
|
||||
hosts={hosts}
|
||||
hosts={visibleHosts}
|
||||
inboundOptions={inboundOptions}
|
||||
loading={loading}
|
||||
isMobile={isMobile}
|
||||
@@ -218,6 +247,8 @@ export default function HostsPage() {
|
||||
onDelete={onDelete}
|
||||
onToggleEnable={onToggleEnable}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
reordering={pendingOrder !== null}
|
||||
onBulkEnable={onBulkEnable}
|
||||
onBulkDelete={onBulkDelete}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
import type { SubProfileMode, SubHostSortMode } from '@/schemas/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
@@ -95,6 +95,22 @@ export default function SubscriptionGeneralTab({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHostSortMode')}
|
||||
description={t('pages.settings.subHostSortModeDesc')}
|
||||
>
|
||||
<Select
|
||||
id="sub-host-sort-mode"
|
||||
aria-label={t('pages.settings.subHostSortMode')}
|
||||
value={allSetting.subHostSortMode}
|
||||
onChange={(value: SubHostSortMode) => updateSetting({ subHostSortMode: value })}
|
||||
options={[
|
||||
{ value: 'inbound-first', label: t('pages.settings.subHostSortInbound') },
|
||||
{ value: 'hosts-first', label: t('pages.settings.subHostSortHosts') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subListen')}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { arrJoin, originalRuleIndex } from './helpers';
|
||||
import type { RuleRow } from './types';
|
||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||
import type { RuleObject } from '@/schemas/routing';
|
||||
import { usePointerRowReorder } from '@/hooks/usePointerRowReorder';
|
||||
import './RoutingTab.css';
|
||||
|
||||
interface RoutingTabProps {
|
||||
@@ -50,19 +51,7 @@ export default function RoutingTab({
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||||
const dragRef = useRef<{
|
||||
from: number | null;
|
||||
to: number | null;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
}>({
|
||||
from: null,
|
||||
to: null,
|
||||
startY: 0,
|
||||
moved: false,
|
||||
});
|
||||
const rulesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const routingScript = templateSettings?.routing?.script ?? '';
|
||||
const rules = useMemo(
|
||||
@@ -273,43 +262,15 @@ export default function RoutingTab({
|
||||
});
|
||||
}
|
||||
|
||||
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
|
||||
if (ev.button != null && ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
try {
|
||||
(ev.currentTarget as Element).setPointerCapture(ev.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false };
|
||||
setDraggedIndex(idx);
|
||||
setDropTargetIndex(idx);
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const state = dragRef.current;
|
||||
if (state.from == null) return;
|
||||
if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return;
|
||||
state.moved = true;
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!el) return;
|
||||
const target = el.closest('[data-row-key]');
|
||||
if (!target) return;
|
||||
const newIdx = Number(target.getAttribute('data-row-key'));
|
||||
if (Number.isFinite(newIdx) && newIdx !== state.to) {
|
||||
state.to = newIdx;
|
||||
setDropTargetIndex(newIdx);
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
document.removeEventListener('pointercancel', onUp);
|
||||
const { from, to, moved } = dragRef.current;
|
||||
dragRef.current = { from: null, to: null, startY: 0, moved: false };
|
||||
setDraggedIndex(null);
|
||||
setDropTargetIndex(null);
|
||||
if (!moved || from == null || to == null || from === to) return;
|
||||
const { drag, onPointerDown: onHandlePointerDown } = usePointerRowReorder({
|
||||
containerRef: rulesContainerRef,
|
||||
getRowKey: (row) => {
|
||||
const key = Number(row.dataset.rowKey);
|
||||
// Desktop table keys are original rule indices; mobile cards use visible positions.
|
||||
const index = isMobile ? key : rows.findIndex((rule) => rule.key === key);
|
||||
return Number.isInteger(index) && index >= 0 && index < rows.length ? index : null;
|
||||
},
|
||||
onDrop: ({ from, to }) => {
|
||||
const fromOrig = originalRuleIndex(rowsRef.current, from);
|
||||
const toOrig = originalRuleIndex(rowsRef.current, to);
|
||||
mutate((tt) => {
|
||||
@@ -318,12 +279,10 @@ export default function RoutingTab({
|
||||
const [movedItem] = list.splice(fromOrig, 1);
|
||||
list.splice(toOrig, 0, movedItem);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
document.addEventListener('pointercancel', onUp);
|
||||
}
|
||||
},
|
||||
});
|
||||
const draggedIndex = drag?.from ?? null;
|
||||
const dropTargetIndex = drag?.to ?? null;
|
||||
|
||||
const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute);
|
||||
const hasBalancer = rows.some((r) => r.balancerTag);
|
||||
@@ -412,41 +371,42 @@ export default function RoutingTab({
|
||||
</Dropdown>
|
||||
</Space>
|
||||
|
||||
{isMobile ? (
|
||||
<RuleCardList
|
||||
rows={rows}
|
||||
draggedIndex={draggedIndex}
|
||||
dropTargetIndex={dropTargetIndex}
|
||||
onHandlePointerDown={onHandlePointerDown}
|
||||
openEdit={openEdit}
|
||||
moveUp={moveUp}
|
||||
moveDown={moveDown}
|
||||
confirmDelete={confirmDelete}
|
||||
toggleRule={toggleRule}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={desktopColumns}
|
||||
dataSource={rows}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
scroll={{ x: tableScrollX }}
|
||||
size="small"
|
||||
className="routing-table"
|
||||
onRow={(_record, index) => {
|
||||
const classes: string[] = [];
|
||||
const i = index ?? -1;
|
||||
if (draggedIndex === i) classes.push('row-dragging');
|
||||
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
|
||||
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
|
||||
}
|
||||
return {
|
||||
className: classes.join(' '),
|
||||
'data-row-key': i,
|
||||
} as React.HTMLAttributes<HTMLElement>;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div ref={rulesContainerRef}>
|
||||
{isMobile ? (
|
||||
<RuleCardList
|
||||
rows={rows}
|
||||
draggedIndex={draggedIndex}
|
||||
dropTargetIndex={dropTargetIndex}
|
||||
onHandlePointerDown={onHandlePointerDown}
|
||||
openEdit={openEdit}
|
||||
moveUp={moveUp}
|
||||
moveDown={moveDown}
|
||||
confirmDelete={confirmDelete}
|
||||
toggleRule={toggleRule}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={desktopColumns}
|
||||
dataSource={rows}
|
||||
rowKey={(r) => r.key}
|
||||
pagination={false}
|
||||
scroll={{ x: tableScrollX }}
|
||||
size="small"
|
||||
className="routing-table"
|
||||
onRow={(_record, index) => {
|
||||
const classes: string[] = [];
|
||||
const i = index ?? -1;
|
||||
if (draggedIndex === i) classes.push('row-dragging');
|
||||
if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) {
|
||||
classes.push(i > draggedIndex ? 'drop-after' : 'drop-before');
|
||||
}
|
||||
return {
|
||||
className: classes.join(' '),
|
||||
};
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,9 @@ const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLead
|
||||
export const SubProfileModeSchema = z.enum(['none', 'builtin', 'custom']);
|
||||
export type SubProfileMode = z.infer<typeof SubProfileModeSchema>;
|
||||
|
||||
export const SubHostSortModeSchema = z.enum(['inbound-first', 'hosts-first']);
|
||||
export type SubHostSortMode = z.infer<typeof SubHostSortModeSchema>;
|
||||
|
||||
export const AllSettingSchema = z
|
||||
.object({
|
||||
webListen: z.string().optional(),
|
||||
@@ -26,6 +29,7 @@ export const AllSettingSchema = z
|
||||
remarkTemplate: z.string().optional(),
|
||||
subShowIdentityOnAllLinks: z.boolean().optional(),
|
||||
subInfoNodeEnable: z.boolean().optional(),
|
||||
subHostSortMode: SubHostSortModeSchema.optional(),
|
||||
subCalendarExpireInclusive: z.boolean().optional(),
|
||||
subExpiredTemplate: z.string().optional(),
|
||||
subTrafficDepletedTemplate: z.string().optional(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user