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:
hamed
2026-08-08 11:40:17 +03:30
parent 934405c42d
commit 47323daa27
36 changed files with 3461 additions and 12686 deletions
@@ -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>
);
}
+3 -2
View File
@@ -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', () => ({
+2 -14
View File
@@ -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>}
+10
View File
@@ -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"] سفید می‌ماند. */