feat(frontend): advanced JSON tab on InboundFormModal.new.tsx (Pattern A)

Adds the advanced JSON tab. Each sub-tab (settings / streamSettings /
sniffing) renders an AdvancedSliceEditor — a small CodeMirror-backed
JsonEditor that holds a local text buffer and forwards parsed JSON to
form state on every valid edit.

Invalid JSON sits silently in the local buffer; once the user finishes
balancing braces / quoting, the next valid parse pushes through to the
form. No stamping ref, no apply-on-tab-switch ceremony — the form is
the single source of truth.

The buffer seeds once from form state on mount. The Modal's
destroyOnHidden means each open is a fresh editor instance, so external
form mutations during a single open session can't desync the editor
either.

The streamSettings sub-tab is omitted when streamEnabled is false
(matching the legacy modal's behavior for protocols like Http / Mixed
that have no stream layer).
This commit is contained in:
MHSanaei
2026-05-26 11:33:59 +02:00
parent 40d17b5e59
commit d6d0c3bb41

View File

@@ -55,6 +55,9 @@ import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
import DateTimePicker from '@/components/DateTimePicker';
import InputAddon from '@/components/InputAddon';
import JsonEditor from '@/components/JsonEditor';
import type { FormInstance } from 'antd';
import type { NamePath } from 'antd/es/form/interface';
const { TextArea } = Input;
import type { DBInbound } from '@/models/dbinbound';
@@ -67,6 +70,44 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
const { Text } = Typography;
// Sub-editor for one slice of the form (settings, streamSettings, sniffing).
// Holds a local text buffer so the user can type freely; on every keystroke
// we try to JSON.parse and forward the result to form state. Invalid JSON
// is held in the buffer until the next valid moment — no panic on partial
// input. The buffer seeds once on mount; the modal's destroyOnHidden makes
// each open a fresh editor instance, so we don't need to re-sync on outer
// form changes.
function AdvancedSliceEditor({
form,
path,
minHeight,
maxHeight,
}: {
form: FormInstance<InboundFormValues>;
path: NamePath;
minHeight?: string;
maxHeight?: string;
}) {
const [text, setText] = useState(() =>
JSON.stringify(form.getFieldValue(path) ?? {}, null, 2),
);
return (
<JsonEditor
value={text}
minHeight={minHeight}
maxHeight={maxHeight}
onChange={(next) => {
setText(next);
try {
form.setFieldValue(path, JSON.parse(next));
} catch {
}
}}
/>
);
}
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
@@ -1855,6 +1896,51 @@ export default function InboundFormModalNew({
</>
);
const advancedTab = (
<Tabs
items={[
{
key: 'settings',
label: t('pages.inbounds.advanced.settings'),
children: (
<AdvancedSliceEditor
form={form}
path="settings"
minHeight="320px"
maxHeight="540px"
/>
),
},
...(streamEnabled
? [{
key: 'stream',
label: t('pages.inbounds.advanced.stream'),
children: (
<AdvancedSliceEditor
form={form}
path="streamSettings"
minHeight="320px"
maxHeight="540px"
/>
),
}]
: []),
{
key: 'sniffing',
label: t('pages.inbounds.advanced.sniffing'),
children: (
<AdvancedSliceEditor
form={form}
path="sniffing"
minHeight="240px"
maxHeight="420px"
/>
),
},
]}
/>
);
const sniffingTab = (
<>
<Form.Item name={['sniffing', 'enabled']} label={t('enable')} valuePropName="checked">
@@ -1957,6 +2043,7 @@ export default function InboundFormModalNew({
]
: []),
{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab },
{ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab },
]} />
</Form>
</Modal>