feat: add DocumentEditor with dirty tracking and save
This commit is contained in:
parent
020ddac994
commit
e341a56fb2
71
frontend/src/components/DocumentEditor.tsx
Normal file
71
frontend/src/components/DocumentEditor.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { getDocument, saveDocument } from '../api/client';
|
||||
|
||||
interface DocumentEditorProps {
|
||||
documentKey: string;
|
||||
}
|
||||
|
||||
export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [contentType, setContentType] = useState('text/markdown');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getDocument(documentKey)
|
||||
.then((doc) => {
|
||||
setContent(doc.content);
|
||||
setOriginalContent(doc.content);
|
||||
setContentType(doc.contentType);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('无法加载文档');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [documentKey]);
|
||||
|
||||
const isDirty = content !== originalContent;
|
||||
|
||||
function handleChange(value: string | undefined) {
|
||||
setContent(value ?? '');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaving(true);
|
||||
saveDocument(documentKey, content, contentType)
|
||||
.then(() => {
|
||||
setOriginalContent(content);
|
||||
setSaving(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('保存失败');
|
||||
setSaving(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div data-testid="document-editor-loading">加载中…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div data-testid="document-editor-error">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="document-editor">
|
||||
<div className="doc-header">
|
||||
<span data-testid="save-status">{isDirty ? '未保存' : '已归档'}</span>
|
||||
<button onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
frontend/tests/components/DocumentEditor.test.tsx
Normal file
72
frontend/tests/components/DocumentEditor.test.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import DocumentEditor from '../../src/components/DocumentEditor';
|
||||
import { getDocument, saveDocument } from '../../src/api/client';
|
||||
|
||||
vi.mock('../../src/api/client');
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) => (
|
||||
<textarea
|
||||
data-testid="monaco-editor-mock"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getDocument).mockReset();
|
||||
vi.mocked(saveDocument).mockReset();
|
||||
});
|
||||
|
||||
test('shows a loading state, then loads and displays document content', async () => {
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
||||
|
||||
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
expect(textarea).toHaveValue('# 架构设计');
|
||||
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
|
||||
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
||||
});
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
test('typing marks the document dirty and saving clears the dirty state', async () => {
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
vi.mocked(saveDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
updatedAt: '2026-08-01T10:05:00Z',
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, '# 新内容');
|
||||
|
||||
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存' });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveDocument).toHaveBeenCalledWith('产品文档/架构设计.md', '# 新内容', 'text/markdown');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user