feat(blog-review): enhance BlogReviewPage with detailed article retrieval and loading state

This commit is contained in:
hamed
2026-08-04 20:38:09 +03:30
parent 9d767a6c63
commit f9bbdf1e7c
3 changed files with 187 additions and 6 deletions
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '@/test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '@/lib/api';
import BlogReviewPage from '@/pages/BlogReviewPage';
const get = api.get as ReturnType<typeof vi.fn>;
/** ردیف صف — همان شکل `toListArray()`: بدون body و faq و sources. */
const ROW = {
uuid: 'b-1',
title: 'لیزر موهای زائد چطور کار می‌کند؟',
slug: 'laser',
summary: 'خلاصهٔ کوتاه',
image_url: null,
tags: ['پوست'],
status: 'draft',
review_status: 'pending_review',
topic_slug: 'skin/laser',
created_at: 1_700_000_000,
};
/** پاسخ اندپوینت جزئیات — double-nested، همان قرارداد فرم ویرایش مقاله. */
const DETAIL = {
success: true,
data: {
data: {
...ROW,
body: '<p>متن کامل مقاله اینجاست.</p>',
reading_time: 7,
primary_keyword: 'لیزر موهای زائد',
faq: [{ q: 'چند جلسه لازم است؟', a: 'معمولاً شش تا هشت جلسه.' }],
sources: [{ url: 'https://example.org/a', title: 'راهنمای پوست' }],
},
},
};
function mockApi(detail: unknown = DETAIL) {
get.mockImplementation((url: string) => {
if (url.includes('/review-queue')) {
return Promise.resolve({
success: true,
data: [ROW],
meta: { totalRecords: 1, totalPages: 1, currentPage: 1 },
errors: [],
});
}
return Promise.resolve(detail);
});
}
describe('BlogReviewPage — مودال بازبینی', () => {
beforeEach(() => { get.mockReset(); mockApi(); });
/**
* صف با `toListArray()` ساخته می‌شود و `body` ندارد؛ مودال باید متن کامل را از
* اندپوینت جزئیات بگیرد، وگرنه بازبین یک قاب خالی می‌بیند.
*/
it('با کلیک روی بازبینی، متن کامل مقاله را می‌گیرد و نشان می‌دهد', async () => {
renderWithProviders(<BlogReviewPage />, { route: '/admin/blog-review' });
fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' }));
await waitFor(() => expect(screen.getByText('متن کامل مقاله اینجاست.')).toBeInTheDocument());
expect(get).toHaveBeenCalledWith('/api/v1/admin/blog/b-1');
});
it('پرسش‌های متداول و منابع و برچسب‌ها را هم نشان می‌دهد', async () => {
renderWithProviders(<BlogReviewPage />, { route: '/admin/blog-review' });
fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' }));
await waitFor(() => expect(screen.getByText('پرسش‌های متداول')).toBeInTheDocument());
expect(screen.getByText('چند جلسه لازم است؟')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'راهنمای پوست' })).toHaveAttribute('href', 'https://example.org/a');
expect(screen.getByText('پوست')).toBeInTheDocument();
});
/** بازبین باید بداند چه چیزی را تأیید می‌کند: زمان مطالعه و کلیدواژه و موضوع. */
it('شناسنامهٔ مقاله را بالای متن می‌آورد', async () => {
renderWithProviders(<BlogReviewPage />, { route: '/admin/blog-review' });
fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' }));
await waitFor(() => expect(screen.getByText('۷ دقیقه مطالعه')).toBeInTheDocument());
expect(screen.getByText('کلیدواژه: لیزر موهای زائد')).toBeInTheDocument();
});
/** تا نیامدنِ متن، دکمه‌های تصمیم نباید روی یک قاب خالی بنشینند. */
it('پیش از رسیدن متن، حالت بارگذاری نشان می‌دهد', async () => {
get.mockImplementation((url: string) => {
if (url.includes('/review-queue')) {
return Promise.resolve({
success: true,
data: [ROW],
meta: { totalRecords: 1, totalPages: 1, currentPage: 1 },
errors: [],
});
}
return new Promise(() => {}); // هرگز resolve نمی‌شود
});
renderWithProviders(<BlogReviewPage />, { route: '/admin/blog-review' });
fireEvent.click(await screen.findByRole('button', { name: 'بازبینی' }));
expect(await screen.findByText('در حال بارگذاری مقاله...')).toBeInTheDocument();
});
});
+70 -6
View File
@@ -4,7 +4,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Blog } from '../types';
import { formatDate } from '../lib/utils';
import { formatDate, formatNumber } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { Column } from '../components/ui/DataTable';
import Pagination from '../components/ui/Pagination';
@@ -50,6 +50,22 @@ export default function BlogReviewPage() {
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
/**
* ردیف‌های صف از `toListArray()` می‌آیند و `body`/`faq`/`sources` ندارند — مودال
* بازبینی با آن‌ها یک قاب خالی بود. متن کامل هنگام باز شدن مودال از اندپوینت
* جزئیاتِ موجود گرفته می‌شود، نه با سنگین‌کردن پاسخ فهرست برای ۱۵ ردیف.
*
* شکل پاسخ عمداً double-nested است (`data.data`) — همان قراردادی که فرم ویرایش
* مقاله هم مصرف می‌کند.
*/
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ['admin-blog', preview?.uuid],
queryFn: () => api.get<ApiResponse<{ data: Blog }>>(`/api/v1/admin/blog/${preview!.uuid}`),
enabled: !!preview,
});
const full = detail?.data?.data ?? null;
const approveMutation = useMutation({
mutationFn: (blog: Blog) =>
api.post<ApiResponse<Blog>>(`/api/v1/admin/blog/${blog.uuid}/review`, {
@@ -153,19 +169,67 @@ export default function BlogReviewPage() {
) : undefined
}
>
{preview && (
{preview && detailLoading && !full && (
<p className="muted">در حال بارگذاری مقاله...</p>
)}
{full && (
<div className="blog-review-preview">
{preview.summary && <p className="muted">{preview.summary}</p>}
{full.image_url && (
<img
src={full.image_url}
alt=""
style={{
width: '100%', maxHeight: 320, objectFit: 'cover',
borderRadius: 'var(--r)', marginBottom: 'var(--gap)',
}}
/>
)}
{/* شناسنامهٔ مقاله: بازبین بدون این‌ها نمی‌داند چه چیزی را تأیید می‌کند */}
<div
className="muted"
style={{ display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 12.5, marginBottom: 10 }}
>
{full.topic_slug && <span className="mono">{full.topic_slug}</span>}
{full.reading_time ? <span>{formatNumber(full.reading_time)} دقیقه مطالعه</span> : null}
{full.primary_keyword && <span>کلیدواژه: {full.primary_keyword}</span>}
<span>{formatDate(full.created_at)}</span>
</div>
{full.summary && <p className="muted">{full.summary}</p>}
<div
className="blog-body"
/* محتوای تولیدشده و بازبینی‌شده توسط ادمین است */
dangerouslySetInnerHTML={{ __html: preview.body }}
dangerouslySetInnerHTML={{ __html: full.body }}
/>
{preview.sources && preview.sources.length > 0 && (
{full.faq && full.faq.length > 0 && (
<div style={{ marginTop: 'var(--gap)' }}>
<h4 className="section-title">پرسشهای متداول</h4>
{full.faq.map((f, i) => (
<div key={i} style={{ marginBottom: 10 }}>
<div style={{ fontWeight: 600 }}>{f.q}</div>
<div className="muted">{f.a}</div>
</div>
))}
</div>
)}
{full.tags && full.tags.length > 0 && (
<div style={{ marginTop: 'var(--gap)', display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{full.tags.map((t) => (
<span key={t} className="badge gray">{t}</span>
))}
</div>
)}
{full.sources && full.sources.length > 0 && (
<div className="blog-sources" style={{ marginTop: 'var(--gap)' }}>
<h4 className="section-title">منابع</h4>
<ul>
{preview.sources.map((s) => (
{full.sources.map((s) => (
<li key={s.url}>
<a href={s.url} target="_blank" rel="noopener noreferrer">
{s.title || s.url}
+2
View File
@@ -454,6 +454,8 @@ List blog drafts awaiting review, newest first.
### Response `200`
Paginated (`{ data:[...], meta:{ totalRecords, totalPages, currentPage } }`). Each item is the blog list shape plus `review_status`, `reviewer` (`{ uuid, name }` or `null`), and `topic_slug`.
> **صف، متنِ مقاله را نمی‌دهد.** ردیف‌ها از `Blog::toListArray()` می‌آیند، پس `body`، `faq`، `sources` و فیلدهای SEO در آن‌ها نیست — ۱۵ متن کامل HTML در یک پاسخِ فهرست جایی ندارد. کلاینتی که می‌خواهد مقاله را کامل نشان دهد (مثل مودال بازبینیِ `/admin/blog-review`) باید هنگام باز شدن، `GET /api/v1/admin/blog/{uuid}` را صدا بزند. پاسخ آن double-nested است: `data.data`.
```json
{
"success": true,