feat(settings): move account json editing to dedicated full editor route

This commit is contained in:
plebeius
2026-02-24 20:33:07 +08:00
parent dc43a8438e
commit fb9443a0f8
47 changed files with 852 additions and 219 deletions
@@ -0,0 +1,79 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccountDataEditor from '../account-data-editor';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}));
vi.mock('../../../lib/utils/account-editor-utils', () => ({
buildEditableAccountJson: () => '{"account": {"name": "test"}}',
safeParseAccountJson: vi.fn((text: string) => {
try {
const p = JSON.parse(text);
return p?.account ? p : null;
} catch {
return null;
}
}),
buildSavePayload: vi.fn((parsed: { account: Record<string, unknown> }, id: string) => ({ ...parsed.account, id })),
}));
vi.mock('@plebbit/plebbit-react-hooks', () => ({
useAccount: () => ({ id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }),
setAccount: vi.fn(),
}));
let root: Root;
let container: HTMLDivElement;
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, { initialEntries: ['/settings/account-data'] }, children));
});
};
describe('AccountDataEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders warning gate initially', () => {
render(createElement(AccountDataEditor));
expect(container.textContent).toContain('private_key_warning_title');
});
it('shows go_back and continue buttons in warning gate', () => {
render(createElement(AccountDataEditor));
const buttons = Array.from(container.querySelectorAll('button'));
const texts = buttons.map((b) => b.textContent ?? '');
expect(texts.some((t) => t.includes('go_back'))).toBe(true);
expect(texts.some((t) => t.includes('continue'))).toBe(true);
});
it('does not show editor controls in warning phase', () => {
render(createElement(AccountDataEditor));
const buttons = Array.from(container.querySelectorAll('button'));
const texts = buttons.map((b) => b.textContent ?? '');
expect(texts.some((t) => t.includes('save'))).toBe(false);
expect(texts.some((t) => t.includes('reset_changes'))).toBe(false);
expect(texts.some((t) => t.includes('return_to_settings'))).toBe(false);
});
});
@@ -0,0 +1,54 @@
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.warningGate {
text-align: center;
padding: 40px 20px;
}
.warningTitle {
font-weight: 700;
font-size: 1.2em;
margin-bottom: 12px;
}
.warningDescription {
color: #666;
margin-bottom: 24px;
line-height: 1.5;
}
.warningButtons {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.editorContainer {
font-family: monospace;
font-size: 13px;
}
.controls {
display: flex;
gap: 12px;
margin-top: 16px;
flex-wrap: wrap;
}
.fallbackWarning {
color: #b8860b;
background: #fff8e0;
border: 1px solid #e0c000;
padding: 12px;
margin-bottom: 12px;
}
.loadingMessage {
padding: 20px;
text-align: center;
}
@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { setAccount, useAccount } from '@plebbit/plebbit-react-hooks';
import { buildEditableAccountJson, safeParseAccountJson, buildSavePayload } from '../../lib/utils/account-editor-utils';
import styles from './account-data-editor.module.css';
const DEFAULT_RETURN_TO = '/subs/settings#account-settings';
const loadAce = async () => {
const [aceModule] = await Promise.all([import('react-ace'), import('ace-builds/src-noconflict/mode-json'), import('ace-builds/src-noconflict/theme-monokai')]);
return aceModule.default;
};
const AccountDataEditor = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const account = useAccount();
const returnTo = (location.state as { returnTo?: string } | null)?.returnTo ?? DEFAULT_RETURN_TO;
const [phase, setPhase] = useState<'warning' | 'loading' | 'editor' | 'fallback'>('warning');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [AceEditor, setAceEditor] = useState<React.ComponentType<any> | null>(null);
const [text, setText] = useState('');
useEffect(() => {
if (phase !== 'loading') return;
loadAce()
.then((Editor) => {
setAceEditor(() => Editor);
setText(buildEditableAccountJson(account));
setPhase('editor');
})
.catch(() => {
setText(buildEditableAccountJson(account));
setPhase('fallback');
});
}, [phase, account]);
const handleGoBack = () => navigate(returnTo);
const handleContinue = () => setPhase('loading');
const handleReset = () => setText(buildEditableAccountJson(account));
const handleReturn = () => navigate(returnTo);
const handleSave = async () => {
const parsed = safeParseAccountJson(text);
if (!parsed) {
alert('Invalid JSON');
return;
}
const payload = buildSavePayload(parsed, account?.id);
try {
await setAccount(payload);
navigate(returnTo);
window.location.reload();
} catch (e) {
alert(e instanceof Error ? e.message : 'Error saving');
}
};
if (phase === 'warning') {
return (
<div className={styles.container}>
<div className={styles.warningGate}>
<div className={styles.warningTitle}>{t('private_key_warning_title')}</div>
<div className={styles.warningDescription}>{t('private_key_warning_description')}</div>
<div className={styles.warningButtons}>
<button type='button' onClick={handleGoBack}>
{t('go_back')}
</button>
<button type='button' onClick={handleContinue}>
{t('continue')}
</button>
</div>
</div>
</div>
);
}
if (phase === 'loading') {
return (
<div className={styles.container}>
<div className={styles.loadingMessage}>{t('loading_editor')}</div>
</div>
);
}
return (
<div className={styles.container}>
{phase === 'fallback' && <div className={styles.fallbackWarning}>{t('editor_fallback_warning')}</div>}
<div className={styles.editorContainer}>
{phase === 'editor' && AceEditor ? (
<AceEditor mode='json' theme='monokai' width='100%' height='500px' fontSize={13} showPrintMargin={false} value={text} onChange={setText} />
) : (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
style={{ width: '100%', height: '500px', fontFamily: 'monospace', fontSize: 13 }}
spellCheck={false}
/>
)}
</div>
<div className={styles.controls}>
<button type='button' onClick={handleSave}>
{t('save')}
</button>
<button type='button' onClick={handleReset}>
{t('reset_changes')}
</button>
<button type='button' onClick={handleReturn}>
{t('return_to_settings')}
</button>
</div>
</div>
);
};
export default AccountDataEditor;
+1
View File
@@ -0,0 +1 @@
export { default } from './account-data-editor';