refactor(json-tools): 重构状态管理逻辑并实现输入内容持久化
This commit is contained in:
@@ -64,4 +64,15 @@ describe('useJsonTools 窄屏手动比对', () => {
|
|||||||
act(() => result.current.toggleCollapseA());
|
act(() => result.current.toggleCollapseA());
|
||||||
expect(result.current.collapsedA).toBe(false);
|
expect(result.current.collapsedA).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('单输入经防抖后持久化,重挂载可恢复', async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
const { result, unmount } = renderHook(() => useJsonTools());
|
||||||
|
act(() => result.current.setInput('{"x":1}'));
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
const { result: result2 } = renderHook(() => useJsonTools());
|
||||||
|
expect(result2.current.input).toBe('{"x":1}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import JsonResultPanel from './JsonResultPanel';
|
import JsonResultPanel from './JsonResultPanel';
|
||||||
import CollapsiblePanel from './CollapsiblePanel';
|
import CollapsiblePanel from './CollapsiblePanel';
|
||||||
import { validateJson } from '@/utils/jsonFormatter';
|
import { validateJson } from '@/utils/jsonFormatter';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { buildPreview } from '../useJsonTools';
|
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
|
||||||
import type { ConvertFunction, ConvertResult } from '../types';
|
import type { ConvertFunction, ConvertResult } from '../types';
|
||||||
|
|
||||||
const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: string }> = {
|
const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: string }> = {
|
||||||
@@ -23,29 +23,23 @@ const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: st
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
tools: UseJsonToolsReturn;
|
||||||
mode: string;
|
mode: string;
|
||||||
convertFunction: ConvertFunction;
|
convertFunction: ConvertFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function JsonConvertSection({
|
export default function JsonConvertSection({
|
||||||
|
tools,
|
||||||
mode,
|
mode,
|
||||||
convertFunction,
|
convertFunction,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: JsonConvertSectionProps) {
|
}: JsonConvertSectionProps) {
|
||||||
const [input, setInput] = useState('');
|
const { input, setInput, debouncedInput } = tools;
|
||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
|
||||||
const [inputCollapsed, setInputCollapsed] = useState(false);
|
const [inputCollapsed, setInputCollapsed] = useState(false);
|
||||||
|
|
||||||
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
|
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(() => {
|
|
||||||
setDebouncedInput(input);
|
|
||||||
}, 250);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
const error = useMemo(() => {
|
const error = useMemo(() => {
|
||||||
return validateJson(debouncedInput);
|
return validateJson(debouncedInput);
|
||||||
}, [debouncedInput]);
|
}, [debouncedInput]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
formatJson,
|
formatJson,
|
||||||
type JsonFormatOptions,
|
type JsonFormatOptions,
|
||||||
@@ -11,22 +11,18 @@ import CollapsiblePanel from './CollapsiblePanel';
|
|||||||
import JsonResultPanel from './JsonResultPanel';
|
import JsonResultPanel from './JsonResultPanel';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { buildPreview } from '../useJsonTools';
|
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
|
||||||
|
|
||||||
export default function JsonFormatSection() {
|
interface JsonFormatSectionProps {
|
||||||
const [input, setInput] = useState('');
|
tools: UseJsonToolsReturn;
|
||||||
const [debouncedInput, setDebouncedInput] = useState('');
|
}
|
||||||
|
|
||||||
|
export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
|
||||||
|
const { input, setInput, debouncedInput } = tools;
|
||||||
const [indentSize, setIndentSize] = useState<number>(2);
|
const [indentSize, setIndentSize] = useState<number>(2);
|
||||||
const [sortKeys, setSortKeys] = useState(false);
|
const [sortKeys, setSortKeys] = useState(false);
|
||||||
const [inputCollapsed, setInputCollapsed] = useState(false);
|
const [inputCollapsed, setInputCollapsed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handle = setTimeout(() => {
|
|
||||||
setDebouncedInput(input);
|
|
||||||
}, 250);
|
|
||||||
return () => clearTimeout(handle);
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
const error = useMemo(() => {
|
const error = useMemo(() => {
|
||||||
return validateJson(debouncedInput);
|
return validateJson(debouncedInput);
|
||||||
}, [debouncedInput]);
|
}, [debouncedInput]);
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import JsonFormatSection from '../JsonFormatSection';
|
import JsonFormatSection from '../JsonFormatSection';
|
||||||
|
import type { UseJsonToolsReturn } from '../../useJsonTools';
|
||||||
|
|
||||||
|
function Harness({ initial = '' }: { initial?: string }) {
|
||||||
|
const [input, setInput] = useState(initial);
|
||||||
|
const [debouncedInput, setDebouncedInput] = useState(initial);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedInput(input), 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [input]);
|
||||||
|
const tools = { input, setInput, debouncedInput } as unknown as UseJsonToolsReturn;
|
||||||
|
return <JsonFormatSection tools={tools} />;
|
||||||
|
}
|
||||||
|
|
||||||
describe('JsonFormatSection', () => {
|
describe('JsonFormatSection', () => {
|
||||||
it('渲染输入面板标题与缩进/键名排序工具栏', () => {
|
it('渲染输入面板标题与缩进/键名排序工具栏', () => {
|
||||||
render(<JsonFormatSection />);
|
render(<Harness />);
|
||||||
expect(screen.getByText('JSON 输入')).toBeInTheDocument();
|
expect(screen.getByText('JSON 输入')).toBeInTheDocument();
|
||||||
expect(screen.getByText('缩进')).toBeInTheDocument();
|
expect(screen.getByText('缩进')).toBeInTheDocument();
|
||||||
expect(screen.getByText('键名排序')).toBeInTheDocument();
|
expect(screen.getByText('键名排序')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('输入无效 JSON 时不渲染结果面板与占位区', async () => {
|
it('输入无效 JSON 时不渲染结果面板与占位区', async () => {
|
||||||
render(<JsonFormatSection />);
|
render(<Harness initial="{ invalid json " />);
|
||||||
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
|
|
||||||
fireEvent.change(input, { target: { value: '{ invalid json ' } });
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText('格式化结果')).not.toBeInTheDocument();
|
expect(screen.queryByText('格式化结果')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -25,9 +36,7 @@ describe('JsonFormatSection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('输入有效 JSON 后渲染格式化结果面板', async () => {
|
it('输入有效 JSON 后渲染格式化结果面板', async () => {
|
||||||
render(<JsonFormatSection />);
|
render(<Harness initial='{"a":1,"b":2}' />);
|
||||||
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
|
|
||||||
fireEvent.change(input, { target: { value: '{"a":1,"b":2}' } });
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('格式化结果')).toBeInTheDocument();
|
expect(screen.getByText('格式化结果')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -37,20 +46,17 @@ describe('JsonFormatSection', () => {
|
|||||||
|
|
||||||
it('点击标题可折叠输入面板', async () => {
|
it('点击标题可折叠输入面板', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<JsonFormatSection />);
|
render(<Harness initial='{"a":1}' />);
|
||||||
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
|
|
||||||
fireEvent.change(input, { target: { value: '{"a":1}' } });
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('格式化结果')).toBeInTheDocument();
|
expect(screen.getByText('格式化结果')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('JSON 输入'));
|
await user.click(screen.getByText('JSON 输入'));
|
||||||
|
|
||||||
// 折叠后输入框内容区隐藏
|
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(input.closest('.opacity-0')).toBeInTheDocument();
|
expect(input.closest('.opacity-0')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
// 折叠态展示单行预览
|
|
||||||
expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument();
|
expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ export default function Index() {
|
|||||||
{pageMode === 'diff' ? (
|
{pageMode === 'diff' ? (
|
||||||
<DiffWorkspace tools={tools} />
|
<DiffWorkspace tools={tools} />
|
||||||
) : pageMode === 'format' ? (
|
) : pageMode === 'format' ? (
|
||||||
<JsonFormatSection />
|
<JsonFormatSection tools={tools} />
|
||||||
) : pageMode === 'yaml' ? (
|
) : pageMode === 'yaml' ? (
|
||||||
<JsonConvertSection mode="yaml" convertFunction={yamlConvert} />
|
<JsonConvertSection tools={tools} mode="yaml" convertFunction={yamlConvert} />
|
||||||
) : pageMode === 'toml' ? (
|
) : pageMode === 'toml' ? (
|
||||||
<JsonConvertSection mode="toml" convertFunction={tomlConvert} />
|
<JsonConvertSection tools={tools} mode="toml" convertFunction={tomlConvert} />
|
||||||
) : (
|
) : (
|
||||||
<JsonConvertSection mode="minify" convertFunction={minifyConvert} />
|
<JsonConvertSection tools={tools} mode="minify" convertFunction={minifyConvert} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface UseJsonToolsReturn {
|
|||||||
setRightInput: (val: string) => void;
|
setRightInput: (val: string) => void;
|
||||||
leftError: string | null;
|
leftError: string | null;
|
||||||
rightError: string | null;
|
rightError: string | null;
|
||||||
|
/** format/yaml/toml/minify 共享的输入内容(已持久化) */
|
||||||
|
input: string;
|
||||||
|
setInput: (val: string) => void;
|
||||||
|
/** input 防抖后的快照,用于格式化/转换计算与持久化 */
|
||||||
|
debouncedInput: string;
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
setViewMode: (mode: ViewMode) => void;
|
setViewMode: (mode: ViewMode) => void;
|
||||||
/** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */
|
/** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */
|
||||||
@@ -59,18 +64,34 @@ export function useJsonTools(): UseJsonToolsReturn {
|
|||||||
|
|
||||||
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
||||||
|
|
||||||
const [leftInput, setLeftInputState] = useState('');
|
const [persistedLeft, setPersistedLeft] = useStorageState('jsonTools/diffLeft', '');
|
||||||
const [rightInput, setRightInputState] = useState('');
|
const [persistedRight, setPersistedRight] = useStorageState('jsonTools/diffRight', '');
|
||||||
const [debouncedLeft, setDebouncedLeft] = useState('');
|
const [persistedInput, setPersistedInput] = useStorageState('jsonTools/input', '');
|
||||||
const [debouncedRight, setDebouncedRight] = useState('');
|
|
||||||
|
const [leftInput, setLeftInputState] = useState<string>(persistedLeft);
|
||||||
|
const [rightInput, setRightInputState] = useState<string>(persistedRight);
|
||||||
|
const [input, setInput] = useState<string>(persistedInput);
|
||||||
|
const [debouncedLeft, setDebouncedLeft] = useState<string>(persistedLeft);
|
||||||
|
const [debouncedRight, setDebouncedRight] = useState<string>(persistedRight);
|
||||||
|
const [debouncedInput, setDebouncedInput] = useState<string>(persistedInput);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
setDebouncedLeft(leftInput);
|
setDebouncedLeft(leftInput);
|
||||||
setDebouncedRight(rightInput);
|
setDebouncedRight(rightInput);
|
||||||
|
setPersistedLeft(leftInput);
|
||||||
|
setPersistedRight(rightInput);
|
||||||
}, 250);
|
}, 250);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [leftInput, rightInput]);
|
}, [leftInput, rightInput, setPersistedLeft, setPersistedRight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
setDebouncedInput(input);
|
||||||
|
setPersistedInput(input);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [input, setPersistedInput]);
|
||||||
|
|
||||||
const parseState = useMemo(() => {
|
const parseState = useMemo(() => {
|
||||||
const invalidMsg = '无效的 JSON 格式';
|
const invalidMsg = '无效的 JSON 格式';
|
||||||
@@ -207,6 +228,9 @@ export function useJsonTools(): UseJsonToolsReturn {
|
|||||||
setRightInput,
|
setRightInput,
|
||||||
leftError,
|
leftError,
|
||||||
rightError,
|
rightError,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
debouncedInput,
|
||||||
viewMode: activeViewMode,
|
viewMode: activeViewMode,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
activeResult,
|
activeResult,
|
||||||
|
|||||||
Vendored
+6
@@ -89,6 +89,12 @@ export interface StorageSchema {
|
|||||||
'qrCode/urlExpanded': boolean;
|
'qrCode/urlExpanded': boolean;
|
||||||
/** JSON 工具页面当前子模式 */
|
/** JSON 工具页面当前子模式 */
|
||||||
'jsonTools/pageMode': JsonToolsPageMode;
|
'jsonTools/pageMode': JsonToolsPageMode;
|
||||||
|
/** JSON 工具:format/yaml/toml/minify 共享的输入内容 */
|
||||||
|
'jsonTools/input': string;
|
||||||
|
/** JSON 工具:diff 模式左侧输入内容 */
|
||||||
|
'jsonTools/diffLeft': string;
|
||||||
|
/** JSON 工具:diff 模式右侧输入内容 */
|
||||||
|
'jsonTools/diffRight': string;
|
||||||
/** Base64 转换器页面当前子模式 */
|
/** Base64 转换器页面当前子模式 */
|
||||||
'base64Converter/pageMode': Base64ConverterPageMode;
|
'base64Converter/pageMode': Base64ConverterPageMode;
|
||||||
/** Base64 转换器「文件」子模式当前方向 */
|
/** Base64 转换器「文件」子模式当前方向 */
|
||||||
|
|||||||
Reference in New Issue
Block a user