feat(sub): per-inbound sort order for subscription links

Add a subSortIndex field to inbounds that controls the order of links
in subscription output only: the raw sub body, the HTML sub page, and
the JSON/Clash formats (all served from the same query). Lower values
come first; ties keep id order. The panel inbound list is unaffected.

The value is editable in the inbound form next to the share-address
fields, propagates to nodes via wireInbound, and follows the usual
node-sync rules (copied on import, mirrored while not dirty, never a
structural change).

Rescoped from #5214 by @Ponywka.
This commit is contained in:
MHSanaei
2026-06-12 12:03:22 +02:00
parent 7ae3ea66d1
commit f1a4286e2f
36 changed files with 367 additions and 4 deletions
+33 -1
View File
@@ -6,7 +6,7 @@ import {
formValuesToWirePayload,
type RawInboundRow,
} from '@/lib/xray/inbound-form-adapter';
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -262,3 +262,35 @@ describe('formValuesToWirePayload', () => {
expect(replay.streamSettings).toEqual(original.streamSettings);
});
});
describe('subSortIndex', () => {
it('rawInboundToFormValues defaults to 1 when field is absent', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
expect(values.subSortIndex).toBe(1);
});
it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
});
it('formValuesToWirePayload includes subSortIndex in the payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
const payload = formValuesToWirePayload(values);
expect(payload.subSortIndex).toBe(3);
});
it('subSortIndex round-trips through raw → values → payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
const payload = formValuesToWirePayload(values);
const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
expect(replay.subSortIndex).toBe(42);
});
it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
});
});