feat: add RichTextEditor component for rich text editing in articles
feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||
import {
|
||||
ClassicEditor,
|
||||
Essentials,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
BlockQuote,
|
||||
Table,
|
||||
TableToolbar,
|
||||
Undo,
|
||||
} from 'ckeditor5';
|
||||
import translations from 'ckeditor5/translations/fa.js';
|
||||
import 'ckeditor5/ckeditor5.css';
|
||||
|
||||
/**
|
||||
* ادیتور متن غنی مقالهها.
|
||||
*
|
||||
* تا ۲۰۲۶-۰۸-۰۸ هر دو صفحهٔ مقاله مستقیم `@ckeditor/ckeditor5-build-classic` را
|
||||
* import میکردند. آن پکیج deprecated بود و ۶۲ advisory داشت (یافتهٔ ۴ آدیت
|
||||
* ۲۰۲۶-۰۸-۰۷). جایگزینش پکیج umbrella `ckeditor5` است که در آن، برخلاف build
|
||||
* آماده، فهرست پلاگینها صریح است.
|
||||
*
|
||||
* پیکربندی اینجا متمرکز شد تا مهاجرت بعدی یک فایل باشد نه دو صفحه — و تا نوار
|
||||
* ابزارِ دو صفحه از هم واگرا نشود.
|
||||
*
|
||||
* فهرست پلاگینها دقیقاً همان دکمههای نوار ابزارِ قبلی است، نه بیشتر: هر پلاگین
|
||||
* اضافه یعنی markup تازهای که `html_sanitizer.yaml` هنوز مجازش نکرده و هنگام
|
||||
* ذخیره حذف میشود.
|
||||
*/
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div dir="rtl" className="ck-rtl">
|
||||
<CKEditor
|
||||
editor={ClassicEditor}
|
||||
data={value}
|
||||
onChange={(_evt, editor) => onChange(editor.getData())}
|
||||
config={{
|
||||
licenseKey: 'GPL',
|
||||
language: 'fa',
|
||||
translations: [translations],
|
||||
plugins: [
|
||||
Essentials,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
BlockQuote,
|
||||
Table,
|
||||
TableToolbar,
|
||||
Undo,
|
||||
],
|
||||
toolbar: [
|
||||
'heading', '|',
|
||||
'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|',
|
||||
'blockQuote', 'insertTable', '|',
|
||||
'undo', 'redo',
|
||||
],
|
||||
table: {
|
||||
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { renderWithProviders } from '@/test/utils';
|
||||
|
||||
vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null }));
|
||||
vi.mock('@ckeditor/ckeditor5-build-classic', () => ({ default: {} }));
|
||||
// ادیتور در jsdom بالا نمیآید و به این تست ربطی ندارد؛ کلِ wrapper mock میشود
|
||||
// تا mockهای پکیجهای داخلیاش با هر مهاجرت CKEditor عوض نشوند.
|
||||
vi.mock('../components/RichTextEditor', () => ({ default: () => null }));
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('@/components/ui/SearchableSelect', () => ({ default: () => null }));
|
||||
vi.mock('@/lib/api', () => ({
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
||||
import RichTextEditor from '../components/RichTextEditor';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Blog, City } from '../types';
|
||||
@@ -174,18 +173,7 @@ export default function BlogFormPage() {
|
||||
control={control}
|
||||
name="body"
|
||||
render={({ field }) => (
|
||||
<div dir="rtl" className="ck-rtl">
|
||||
<CKEditor
|
||||
editor={ClassicEditor as never}
|
||||
data={field.value ?? ''}
|
||||
onChange={(_evt: unknown, editor: { getData: () => string }) => field.onChange(editor.getData())}
|
||||
config={{
|
||||
licenseKey: 'GPL',
|
||||
language: 'fa',
|
||||
toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RichTextEditor value={field.value ?? ''} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
{errors.body && <p className="text-[var(--danger)] text-xs mt-1">{errors.body.message}</p>}
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
||||
import RichTextEditor from '../components/RichTextEditor';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Blog } from '../types';
|
||||
@@ -117,14 +116,7 @@ export default function RepresentationBlogFormPage() {
|
||||
control={control}
|
||||
name="body"
|
||||
render={({ field }) => (
|
||||
<div dir="rtl" className="ck-rtl">
|
||||
<CKEditor
|
||||
editor={ClassicEditor as never}
|
||||
data={field.value ?? ''}
|
||||
onChange={(_e: unknown, editor: { getData: () => string }) => field.onChange(editor.getData())}
|
||||
config={{ licenseKey: 'GPL', language: 'fa', toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'] }}
|
||||
/>
|
||||
</div>
|
||||
<RichTextEditor value={field.value ?? ''} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
{errors.body && <p className="text-[var(--danger)] text-xs mt-1">{errors.body.message}</p>}
|
||||
|
||||
@@ -1020,6 +1020,16 @@ html, body { max-width: 100%; overflow-x: hidden; }
|
||||
.wh-two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--gap); align-items: start; }
|
||||
@media (max-width: 1100px) { .wh-two-col { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* ── بدنهٔ مقاله در صفحهٔ بازبینی ───────────────────────────────────────────
|
||||
جدولهای مقالهها تا ۲۰۲۶-۰۸-۰۸ ظاهرشان را از `style` inline میگرفتند. آن
|
||||
attribute در sanitizer عمداً ممنوع است — تنها attributeِ ظاهریِ جدول که
|
||||
میتواند بارِ اجرایی حمل کند — پس همان ظاهر اینجا، در لایهٔ درست، بازسازی
|
||||
میشود. `border` و `cellpadding` هنوز از خودِ HTML میآیند. */
|
||||
.blog-body table { border-collapse: collapse; width: 100%; }
|
||||
.blog-body th, .blog-body td { border: 1px solid var(--border); padding: 8px; }
|
||||
.blog-body th { background: var(--surface-2); font-weight: 600; }
|
||||
.blog-body img { max-width: 100%; height: auto; }
|
||||
|
||||
/* ── CKEditor 5 ────────────────────────────────────────────────────────────
|
||||
ادیتور همهٔ رنگهایش را از متغیرهای --ck-color-* خودش میگیرد و پیشفرض آنها
|
||||
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید میماند. */
|
||||
|
||||
Reference in New Issue
Block a user