feat(blog): add city_id to blogs for city-specific scoping
- Introduced a new nullable city_id column in the blogs table to allow scoping of blog posts to specific cities. - Updated Blog entity to include a ManyToOne relationship with the City entity. - Enhanced BlogController to handle city_id in the request, allowing filtering of posts by city. - Modified BlogRepository to support querying published posts based on city_id. - Added tests to ensure correct behavior for city-scoped and nationwide posts, including creation and updating of posts with city associations.
This commit is contained in:
@@ -16,9 +16,13 @@ import { api } from '@/lib/api';
|
||||
import BlogFormPage from '@/pages/BlogFormPage';
|
||||
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
// فرم شهرها را برای انتخابگر «سراسری / شهر» میگیرد
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||
});
|
||||
|
||||
describe('BlogFormPage — اعتبارسنجی zod (حالت ساخت)', () => {
|
||||
|
||||
@@ -9,7 +9,8 @@ import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Blog } from '../types';
|
||||
import type { Blog, City } from '../types';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
@@ -21,6 +22,8 @@ const schema = z.object({
|
||||
tags: z.string().optional(),
|
||||
status: z.enum(['draft', 'published']),
|
||||
image_url: z.string().optional(),
|
||||
// null = مقاله سراسری؛ حالت دائمی است نه مقدار تنظیمنشده
|
||||
city_id: z.number().nullable().optional(),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
@@ -59,6 +62,13 @@ export default function BlogFormPage() {
|
||||
// detail endpoint is double-nested: success(['data' => $blog->toArray()])
|
||||
const blog = data?.data?.data;
|
||||
|
||||
const citiesQuery = useQuery({
|
||||
queryKey: ['cities-select'],
|
||||
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const cityOptions = (citiesQuery.data?.data ?? []).map((c) => ({ value: c.id, label: c.name }));
|
||||
|
||||
const { register, handleSubmit, control, watch, setValue, formState: { errors, isSubmitting } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { status: 'draft' },
|
||||
@@ -70,6 +80,7 @@ export default function BlogFormPage() {
|
||||
tags: blog.tags?.join(', ') ?? '',
|
||||
status: blog.status,
|
||||
image_url: blog.image_url ?? '',
|
||||
city_id: blog.city ? Number(blog.city.id) : null,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -83,6 +94,8 @@ export default function BlogFormPage() {
|
||||
status: d.status,
|
||||
image_url: d.image_url ?? '',
|
||||
tags: d.tags ? d.tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
|
||||
// همیشه فرستاده میشود؛ null یعنی «سراسری» و باید شهر قبلی را پاک کند
|
||||
city_id: d.city_id ?? null,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
@@ -235,6 +248,25 @@ export default function BlogFormPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="">شهر</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city_id"
|
||||
render={({ field }) => (
|
||||
<SearchableSelect
|
||||
options={[{ value: 0, label: 'سراسری (همه شهرها)' }, ...cityOptions]}
|
||||
value={field.value ?? 0}
|
||||
onChange={(v) => field.onChange(v ? Number(v) : null)}
|
||||
placeholder="سراسری (همه شهرها)"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-[12px] text-[var(--text-3)] mt-1">
|
||||
مقاله سراسری روی همه دامنههای شهری نمایش داده میشود؛ مقاله شهری فقط به دامنه همان شهر نسبت داده میشود.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="">تگها (با ویرگول جدا کنید)</label>
|
||||
<input {...register('tags')} dir="ltr" placeholder="tag1, tag2, tag3"
|
||||
|
||||
@@ -428,6 +428,8 @@ export interface Blog {
|
||||
image_url: string | null;
|
||||
status: "draft" | "published";
|
||||
author?: { uuid: string; name: string } | null;
|
||||
/** null = مقاله سراسری (روی همه دامنهها، canonical روی دامنه اصلی) */
|
||||
city?: { id: string; name: string } | null;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
|
||||
Reference in New Issue
Block a user