mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(settings): move account json editing to dedicated full editor route
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './account-data-editor';
|
||||
Reference in New Issue
Block a user