refactor(frontend): read the panel's navigation from one route manifest (#6922)

The router, the sidebar, the page titles and the command palette each kept
their own list of the panel's pages, so a page added to one could go
missing from another without any test noticing. They all read
src/routes/panelRoutes.tsx now, and a contract test holds the manifest to
the router, the translation bundle and the palette keywords.

The unused useZodForm helper goes, and CONTRIBUTING, frontend/CLAUDE.md and
the frontend README describe the useForm + rhfZodValidate pattern the
panel's forms actually use.
This commit is contained in:
25f2002654
2026-10-25 00:18:31 +05:30
committed by GitHub
parent 746bdbe9f4
commit 14c9bdff89
14 changed files with 242 additions and 254 deletions
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CheckCircleFilled,
ClockCircleOutlined,
@@ -13,13 +12,11 @@ import {
ClusterOutlined,
CodeOutlined,
CopyOutlined,
CrownOutlined,
DashboardOutlined,
DatabaseOutlined,
DiscordOutlined,
ExportOutlined,
FileTextOutlined,
GlobalOutlined,
ImportOutlined,
LoadingOutlined,
MailOutlined,
@@ -31,8 +28,6 @@ import {
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
TelegramFilled,
ToolOutlined,
} from '@ant-design/icons';
@@ -42,6 +37,7 @@ import { activateOnKey } from '@/utils/a11y';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { useTheme } from '@/hooks/useTheme';
import { PANEL_ROUTES } from '@/routes/panelRoutes';
import type { ClientRecord, InboundOption } from '@/schemas/client';
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
import './CommandPalette.css';
@@ -62,6 +58,34 @@ interface PaletteItem {
};
}
// The search terms of every panel page, so the palette finds it by what it
// does rather than by its title.
export const PALETTE_KEYWORDS: Record<string, string[]> = {
'/': ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
'/inbounds': [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
'/clients': ['clients', 'users', 'sub', 'traffic', 'quota'],
'/groups': ['groups', 'tags', 'batch'],
'/nodes': ['nodes', 'servers', 'cluster', 'remote nodes'],
'/hosts': ['hosts', 'sni', 'domains'],
'/outbound': ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
'/routing': ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
'/settings': ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
'/xray': ['xray', 'templates', 'balancer', 'dns'],
'/api-docs': ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
'/sponsors': ['sponsors', 'sponsor', 'partners'],
};
export default function CommandPalette() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
@@ -343,91 +367,12 @@ export default function CommandPalette() {
});
});
const pages = [
{
path: '/',
title: t('menu.dashboard'),
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
icon: <DashboardOutlined aria-hidden />,
},
{
path: '/inbounds',
title: t('menu.inbounds'),
keywords: [
'inbounds',
'ports',
'vless',
'vmess',
'reality',
'trojan',
'shadowsocks',
'wireguard',
'hysteria',
'masque',
],
icon: <ImportOutlined aria-hidden />,
},
{
path: '/clients',
title: t('menu.clients'),
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
icon: <TeamOutlined aria-hidden />,
},
{
path: '/groups',
title: t('menu.groups'),
keywords: ['groups', 'tags', 'batch'],
icon: <TagsOutlined aria-hidden />,
},
{
path: '/nodes',
title: t('menu.nodes'),
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
icon: <ClusterOutlined aria-hidden />,
},
{
path: '/hosts',
title: t('menu.hosts'),
keywords: ['hosts', 'sni', 'domains'],
icon: <GlobalOutlined aria-hidden />,
},
{
path: '/outbound',
title: t('menu.outbounds'),
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
icon: <ExportOutlined aria-hidden />,
},
{
path: '/routing',
title: t('menu.routing'),
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
icon: <SwapOutlined aria-hidden />,
},
{
path: '/settings',
title: t('menu.settings'),
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
icon: <SettingOutlined aria-hidden />,
},
{
path: '/xray',
title: t('menu.xray'),
keywords: ['xray', 'templates', 'balancer', 'dns'],
icon: <ToolOutlined aria-hidden />,
},
{
path: '/api-docs',
title: t('menu.apiDocs'),
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined aria-hidden />,
},
{
path: '/sponsors',
title: t('menu.sponsors'),
keywords: ['sponsors', 'sponsor', 'partners'],
icon: <CrownOutlined aria-hidden />,
},
];
const pages = PANEL_ROUTES.map((route) => ({
path: route.path,
title: t(route.titleKey),
keywords: PALETTE_KEYWORDS[route.path] ?? [],
icon: <route.icon aria-hidden />,
}));
pages
.filter((p) => matches(p.title, undefined, p.keywords))
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
import { FormProvider } from 'react-hook-form';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FormField } from './FormField';
import { useZodForm } from './useZodForm';
const GB = 1024 * 1024 * 1024;
@@ -60,7 +60,8 @@ const ClientSchema = z.object({
});
function ClientDemo() {
const methods = useZodForm(ClientSchema, {
const methods = useForm<z.infer<typeof ClientSchema>>({
resolver: zodResolver(ClientSchema),
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
});
return (
@@ -95,8 +96,11 @@ const TrafficSchema = z.object({
});
function TrafficDemo() {
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
const totalBytes = methods.watch('totalBytes');
const methods = useForm<z.infer<typeof TrafficSchema>>({
resolver: zodResolver(TrafficSchema),
defaultValues: { totalBytes: 50 * GB },
});
const totalBytes = useWatch({ control: methods.control, name: 'totalBytes' });
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
@@ -128,7 +132,10 @@ const InboundSchema = z.object({
});
function ValidationDemo() {
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
const methods = useForm<z.infer<typeof InboundSchema>>({
resolver: zodResolver(InboundSchema),
defaultValues: { remark: '', port: 0 },
});
useEffect(() => {
void methods.trigger();
}, [methods]);
@@ -157,7 +164,8 @@ const RealitySchema = z.object({
});
function NestedDemo() {
const methods = useZodForm(RealitySchema, {
const methods = useForm<z.infer<typeof RealitySchema>>({
resolver: zodResolver(RealitySchema),
defaultValues: {
streamSettings: {
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
@@ -1,5 +1,4 @@
export { FormField, type FormFieldProps } from './FormField';
export { useZodForm } from './useZodForm';
export { rhfZodValidate } from './rhfZodValidate';
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
export { toDotted, type FieldName } from './toDotted';
@@ -1,20 +0,0 @@
import { useForm } from 'react-hook-form';
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
export function useZodForm<TFieldValues extends FieldValues>(
schema: z.ZodType<TFieldValues>,
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
): UseFormReturn<TFieldValues> {
const resolver = zodResolver(
schema as z.ZodType<TFieldValues, TFieldValues>,
) as Resolver<TFieldValues>;
return useForm<TFieldValues>({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldUnregister: false,
...options,
resolver,
});
}