fix(frontend): preserve edited server drafts (#6156)

* fix(frontend): preserve edited server drafts

* fix(frontend): retain Xray server projections

* fix(frontend): keep draft controls internal

* fix(frontend): rehydrate saved redacted settings

* fix(frontend): order saved draft hydration

* fix(frontend): preserve draft baselines on security saves

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
This commit is contained in:
PathGao
2026-07-30 08:59:53 +08:00
committed by GitHub
parent 8d02ae28f5
commit 66740b7ef4
6 changed files with 333 additions and 47 deletions
+78 -1
View File
@@ -1,9 +1,10 @@
import type { ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { keys } from '@/api/queryKeys';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
@@ -25,4 +26,80 @@ describe('useAllSettings', () => {
await waitFor(() => expect(result.current.fetched).toBe(true));
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
});
it('keeps an edited setting when a refetch returns older server data', async () => {
const values = [
{ webPort: 2053 },
{ webPort: 2054 },
];
let index = 0;
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++]));
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.updateSetting({ webPort: 3000 }));
await queryClient.invalidateQueries({ queryKey: keys.settings.all() });
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
expect(result.current.allSetting.webPort).toBe(3000);
expect(result.current.saveDisabled).toBe(false);
});
it('hydrates redacted secrets after a successful save', async () => {
let fetchCount = 0;
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/setting/all') {
fetchCount += 1;
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
}
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
await act(async () => {
await result.current.saveAll();
});
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
expect(result.current.allSetting.tgBotToken).toBe('');
expect(result.current.allSetting.hasTgBotToken).toBe(true);
expect(result.current.saveDisabled).toBe(true);
});
it('establishes a saved baseline for a full-payload security save', async () => {
let fetchCount = 0;
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/setting/all') {
fetchCount += 1;
return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' });
}
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useAllSettings(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.updateSetting({ tgBotToken: 'secret' }));
await act(async () => {
await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' });
});
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3));
expect(result.current.allSetting.tgBotToken).toBe('');
expect(result.current.allSetting.hasTgBotToken).toBe(true);
expect(result.current.saveDisabled).toBe(true);
});
});
@@ -0,0 +1,70 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useXraySetting } from '@/hooks/useXraySetting';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
function xrayPayload(overrides: Record<string, unknown> = {}) {
return {
xraySetting: {},
inboundTags: [],
clientReverseTags: [],
outboundTestUrl: 'https://test.example',
subscriptionOutbounds: [],
subscriptionOutboundTags: [],
...overrides,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
});
describe('useXraySetting', () => {
it('refreshes server-derived outbounds while the editor is dirty', async () => {
let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] });
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useXraySetting(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.setXraySetting('{"outbounds":[]}'));
payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] });
await act(async () => result.current.fetchAll());
await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
expect(result.current.xraySetting).toBe('{"outbounds":[]}');
});
it('keeps the outbound test URL input empty when it is cleared', async () => {
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
return new Msg(true, '');
});
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useXraySetting(), { wrapper });
await waitFor(() => expect(result.current.fetched).toBe(true));
act(() => result.current.setOutboundTestUrl(''));
expect(result.current.outboundTestUrl).toBe('');
expect(result.current.saveDisabled).toBe(true);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { StrictMode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useServerDraft } from '@/hooks/useServerDraft';
describe('useServerDraft', () => {
it('keeps an edited draft when the server refetches', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'edited' }));
rerender({ server: { value: 'two' } });
expect(result.current.draft).toEqual({ value: 'edited' });
expect(result.current.isDirty).toBe(true);
});
it('accepts a refetch that matches the saved draft', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'saved' }));
rerender({ server: { value: 'saved' } });
expect(result.current.draft).toEqual({ value: 'saved' });
expect(result.current.isDirty).toBe(false);
});
it('compares a preserved draft with the latest server value', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'later' }));
rerender({ server: { value: 'saved' } });
act(() => result.current.setDraft({ value: 'one' }));
expect(result.current.isDirty).toBe(true);
});
it('hydrates clean drafts under StrictMode', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{
initialProps: { server: { value: 'one' } },
wrapper: StrictMode,
},
);
rerender({ server: { value: 'two' } });
expect(result.current.draft).toEqual({ value: 'two' });
expect(result.current.isDirty).toBe(false);
});
it('preserves an edit made before the first server response', () => {
const { result, rerender } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: undefined as { value: string } | undefined } },
);
act(() => result.current.setDraft({ value: 'edited' }));
rerender({ server: { value: 'one' } });
expect(result.current.draft).toEqual({ value: 'edited' });
expect(result.current.isDirty).toBe(true);
});
it('marks a sent draft clean before its refetch arrives', () => {
const { result } = renderHook(
({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value),
{ initialProps: { server: { value: 'one' } } },
);
act(() => result.current.setDraft({ value: 'saved' }));
act(() => result.current.markSaved({ value: 'saved' }));
expect(result.current.isDirty).toBe(false);
});
});