mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +03:00
fix(inbounds): preserve formatting in the advanced JSON editor (#6857)
In the inbound form's Advanced → All editor, pressing Enter or adding any whitespace while the JSON stayed valid made the panel replace the operator's text with its own formatting, dropping the new line and moving the caret. Edits that only change formatting are now left alone, while changes made elsewhere in the form still reach the editor, a tag change patches only the tag, and an edit the form rejects (a non-numeric port, a removed section) is replaced by what the form will actually save, so the editor never shows JSON the panel will not submit.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { useEffect } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { AdvancedAllEditor } from '@/pages/inbounds/form/advanced-editors';
|
||||
|
||||
vi.mock('@/components/form', () => ({
|
||||
JsonEditor: ({ value, onChange }: { value: string; onChange: (next: string) => void }) => (
|
||||
<textarea
|
||||
aria-label="Advanced inbound JSON"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
function Harness({ autoTag = false }: { autoTag?: boolean }) {
|
||||
const methods = useForm({
|
||||
defaultValues: {
|
||||
listen: '',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
tag: autoTag ? 'auto-443' : 'initial',
|
||||
settings: { clients: [] },
|
||||
sniffing: { enabled: false },
|
||||
streamSettings: { network: 'tcp' },
|
||||
},
|
||||
});
|
||||
const port = useWatch({ control: methods.control, name: 'port' });
|
||||
|
||||
useEffect(() => {
|
||||
if (autoTag) methods.setValue('tag', `auto-${port}`);
|
||||
}, [autoTag, methods, port]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<AdvancedAllEditor streamEnabled sniffingEnabled />
|
||||
<button type="button" onClick={() => methods.setValue('tag', 'external')}>
|
||||
Update form externally
|
||||
</button>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function flushFormEffects() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function editor(): HTMLTextAreaElement {
|
||||
return screen.getByRole('textbox', { name: 'Advanced inbound JSON' }) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('AdvancedAllEditor JSON buffer (#6854)', () => {
|
||||
it('preserves a valid formatting-only edit instead of removing an entered newline', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
const next = input.value.replace('"port": 443,', '"port": 443,\n');
|
||||
|
||||
fireEvent.change(input, { target: { value: next } });
|
||||
await flushFormEffects();
|
||||
|
||||
expect(input.value).toBe(next);
|
||||
expect(input.value).toContain('"port": 443,\n\n');
|
||||
});
|
||||
|
||||
it('preserves valid edits and reflects external form updates', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
const next = input.value.replace('"tag": "initial"', '"tag": "edited"');
|
||||
|
||||
fireEvent.change(input, { target: { value: next } });
|
||||
await flushFormEffects();
|
||||
|
||||
expect(input.value).toBe(next);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update form externally' }));
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value).tag).toBe('external');
|
||||
expect(input.value).toContain('"tag": "external"');
|
||||
});
|
||||
|
||||
it('does not show a rejected port string when the form still holds the numeric port', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { value: input.value.replace('"port": 443', '"port": "8443"') },
|
||||
});
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value).port).toBe(443);
|
||||
expect(input.value).not.toContain('"port": "8443"');
|
||||
});
|
||||
|
||||
it('does not silently accept removing a still-enabled form slice', async () => {
|
||||
render(<Harness />);
|
||||
const input = editor();
|
||||
const removed = JSON.parse(input.value) as Record<string, unknown>;
|
||||
delete removed.sniffing;
|
||||
|
||||
fireEvent.change(input, { target: { value: JSON.stringify(removed, null, 2) } });
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value).sniffing).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('keeps whitespace when an auto-tag reacts to a port edit', async () => {
|
||||
render(<Harness autoTag />);
|
||||
const input = editor();
|
||||
const formatted = input.value.replace('"port": 443,', '"port": 443,\n');
|
||||
|
||||
fireEvent.change(input, { target: { value: formatted } });
|
||||
await flushFormEffects();
|
||||
fireEvent.change(input, {
|
||||
target: { value: input.value.replace('"port": 443,', '"port": 8443,') },
|
||||
});
|
||||
await flushFormEffects();
|
||||
|
||||
expect(JSON.parse(input.value)).toMatchObject({ port: 8443, tag: 'auto-8443' });
|
||||
expect(input.value).toContain('"port": 8443,\n\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
import JsonEditor from '@/components/form/JsonEditor';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
|
||||
describe('JsonEditor external synchronization (#6854)', () => {
|
||||
it('preserves the cursor after an external edit to an earlier JSON field', async () => {
|
||||
const initial = '{ "tag": "old", "port": 443 }';
|
||||
const updated = '{ "tag": "renamed", "port": 443 }';
|
||||
const { container, rerender } = render(
|
||||
<ThemeProvider>
|
||||
<JsonEditor value={initial} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const root = container.querySelector<HTMLElement>('.cm-editor');
|
||||
if (!root) throw new Error('CodeMirror editor was not mounted');
|
||||
const view = EditorView.findFromDOM(root);
|
||||
if (!view) throw new Error('CodeMirror view is missing');
|
||||
const offsetInPort = 3;
|
||||
|
||||
await act(async () => {
|
||||
view.dispatch({ selection: { anchor: initial.indexOf('"port"') + offsetInPort } });
|
||||
});
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<JsonEditor value={updated} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(view.state.doc.toString()).toBe(updated);
|
||||
expect(view.state.selection.main.head).toBe(updated.indexOf('"port"') + offsetInPort);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user