feat(blog-review): enhance BlogReviewPage with detailed article retrieval and loading state
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user