” توی این مقاله قدمبهقدم یک سیستم Dynamic Filter با Next.js و TypeScript میسازیم که فیلترها رو بهصورت داینامیک دریافت و رندر میکنه و وضعیت فیلترها رو داخل URL ذخیره میکنه. “
فرض کنید داریم روی یک فروشگاه اینترنتی کار میکنیم که چندین دستهبندی مختلف داره.
برای یک فروشگاه ساده، احتمالاً فیلترهایی مثل برند، رنگ، قیمت و موجودی کافی هستن و میتونیم همین فیلترها رو مستقیم داخل صفحه محصولات قرار بدیم.
ولی وقتی تعداد دستهبندیها زیاد بشه، شرایط فرق میکنه.
مثلاً برای دستهبندی موبایل ممکنه این فیلترها رو داشته باشیم:
برند
حافظه RAM
حافظه داخلی
سیستمعامل
و..
برای کفش:
برند
سایز
رنگ
جنس
و..
و برای لپتاپ:
برند
پردازنده
RAM
حافظه
اندازه صفحهنمایش
و..
در چنین شرایطی بهتره تعریف فیلترها رو از خود صفحه جدا کنیم.
یعنی بکاند به ما بگه برای این دستهبندی چه فیلترهایی وجود داره، هر فیلتر چه نوعی داره و چه گزینههایی باید نمایش داده بشن. فرانتاند هم بر اساس همین اطلاعات، کامپوننت مناسب رو نمایش بده.
اینجاست که Dynamic Filter میتونه یه راهحل درست و مناسب باشه.
بهجای اینکه خود UI تصمیم بگیره چه فیلترهایی وجود دارند، یک API تعریف فیلترها را برمیگردونه و UI بر اساس اون تعریف، فیلترها رو میسازه.
مثلاً API میتونه چنین دادهای رو به ما بده:
[
{
"key": "brand",
"label": "برند",
"type": "checkbox",
"options": [
{
"value": "apple",
"label": "اپل"
},
{
"value": "samsung",
"label": "سامسونگ"
}
]
},
{
"key": "ram",
"label": "حافظه RAM",
"type": "checkbox",
"options": [
{
"value": "8",
"label": "8 گیگ"
},
{
"value": "12",
"label": "12 گیگ"
}
]
},
{
"key": "available",
"label": "فقط کالاهای موجود",
"type": "switch"
}
]توی این ساختار، type مشخص میکنه که هر فیلتر با چه کامپوننتی باید نمایش داده بشه.
پس UI لازم نیست بدونه برای هر دسته چه فیلترهایی وجود داره؛ فقط تعریف فیلتر رو دریافت میکنه و بر اساس اون UI مناسب رو میسازه.
توی این مقاله همین سیستم رو از صفر پیاده میکنیم :)
برای شروع یک پروژه جدید Next.js میسازیم:
npx create-next-app@latest dynamic-filtersپروژه رو با TypeScript و App Router ایجاد میکنیم.
ساختار اولیهای که در ادامه خواهیم داشت تقریباً به این شکل هستش:
app/
├── api/
│ └── filters/
│ └── route.ts
└── products/
└── page.tsx
components/
└── filters/
├── CheckboxFilter.tsx
├── SwitchFilter.tsx
├── FilterRenderer.tsx
├── ProductFilters.tsx
└── filter-components.ts
lib/
└── filters.ts
types/
└── filter.tsحالا مرحلهبهمرحله این ساختار رو با هم میسازیم.
چون API قرار هست اطلاعات فیلترها رو برگردونه، اول باید مشخص کنیم این داده دقیقاً به چه شکلی هست.
این بخش مهمه چون بعداً تمام کامپوننتها و منطق فیلتر بر اساس همین ساختار کار میکنند.
فایل زیر رو ایجاد کنید:
types/filter.tsابتدا تایپ گزینههای فیلتر رو تعریف میکنیم:
export type FilterOption = {
value: string;
label: string;
};حالا تایپ یک فیلتر از نوع checkbox:
export type CheckboxFilter = {
key: string;
label: string;
type: "checkbox";
options: FilterOption[];
};و تایپ یک فیلتر از نوع switch:
export type SwitchFilter = {
key: string;
label: string;
type: "switch";
};حالا میتونیم این دو تا رو به یک Union Type تبدیل کنیم:
export type FilterConfig =
| CheckboxFilter
| SwitchFilter;در نتیجه FilterConfig میتونه یکی از این دو نوع باشه.
نکته مهم اینجاست که type فقط یک string معمولی نیست.
وقتی مینویسیم:
type: "checkbox";در واقع داریم به تایپ اسکریپت میگیم این مقدار دقیقاً "checkbox" است.
همین مقدار بعداً به ما اجازه میده بین انواع مختلف فیلتر ارتباط برقرار کنیم.
برای دسترسی راحتتر به نوعهای فیلتر هم میتونیم بنویسیم:
export type FilterType =
FilterConfig["type"];در حال حاضر نتیجه این تایپ برابر هست با:
"checkbox" | "switch"حالا باید مشخص کنیم هر نوع فیلتر چه مقداری داره.
فیلتر از نوع Checkbox میتونه چند مقدار داشته باشه:
string[]ولی فیلتر از نوع Switch فقط یک مقدار Boolean داره:
booleanپس توی فایل types/filter.ts اضافه میکنیم:
export type FilterValueMap = {
checkbox: string[];
switch: boolean;
};این Map بعداً ارتباط بین نوع فیلتر و مقدار اون رو برای تایپ اسکریپت مشخص میکنه.
مثلاً:
FilterValueMap["checkbox"]برابر است با:
string[]و
FilterValueMap["switch"]برابر است با:
booleanحالا میتونیم Props مشترک فیلترها رو هم تعریف کنیم:
export type FilterProps<T extends FilterType> = {
filter: Extract<FilterConfig, { type: T }>;
value: FilterValueMap[T] | undefined;
onChange: (value: FilterValueMap[T]) => void;
};با این تعریف، وقتی کامپوننتی از نوع checkbox باشه، تایپ اسکریپت خودش میفهمه که value
باید string[] باشه و onChange هم باید string[] دریافت کنه.
برای switch هم همین ارتباط به boolean برقرار میشه.
حالا که قرارداد داده رو تعریف کردیم، میتونیم API رو بسازیم.
در یک پروژه واقعی، این اطلاعات احتمالاً از دیتابیس یا یک سرویس دیگه میان و با توجه به دسته بندی متفاوتاند.
اما برای اینکه شما هم قدم به قدم با مقاله پیش برید، فعلاً یک API ساده داخل خود Next.js میسازیم.
فایل زیر رو ایجاد کنید:
app/api/filters/route.tsاینم کدش:
import type { FilterConfig } from "@/types/filter";
const filters: FilterConfig[] = [
{
key: "brand",
label: "برند",
type: "checkbox",
options: [
{
value: "apple",
label: "اپل",
},
{
value: "samsung",
label: "سامسونگ",
},
],
},
{
key: "ram",
label: "حافظه RAM",
type: "checkbox",
options: [
{
value: "8",
label: "8 گیگ",
},
{
value: "12",
label: "12 گیگ",
},
],
},
{
key: "available",
label: "فقط کالاهای موجود",
type: "switch",
},
];
export async function GET() {
return Response.json(filters);
}حالا با اجرای پروژه:
npm run devمیتونیم به این آدرس بریم:
/api/filtersو همون ساختاری رو ببینیم که قبلتر دیدیم.
اینجا عمداً دادهها رو داخل خود Route Handler نوشتیم تا پروژه بدون دیتابیس قابل اجرا باشه.
در یک پروژه واقعی، همین قرارداد میتونه حفظ بشه ولی دادهها از دیتابیس خونده میشن؛ مثلاً یک دسته بندی ممکنه فیلترهای brand و ram داشته باشه و یک دسته بندی دیگه size و material.
حالا API رو داریم و میدونیم هر فیلتر چه شکلی هست.
باید کامپوننتهای UI رو بسازیم.
فایل زیر رو ایجاد کنید:
components/filters/CheckboxFilter.tsxچون این کامپوننت قراره مقدار انتخابشده رو از State والد دریافت کنه، اون رو به شکل Controlled Component میسازیم.
در کامپوننتهای Controlled، مقدار ورودی از بیرون کامپوننت کنترل میشه و خود کامپوننت فقط تغییرات رو از طریق
onChangeبه والد اعلام میکنه.
"use client";
import type { FilterProps } from "@/types/filter";
type Props = FilterProps<"checkbox">;
export function CheckboxFilter({ filter, value, onChange }: Props) {
const selectedValues = value ?? [];
function handleChange(optionValue: string) {
const isSelected = selectedValues.includes(optionValue);
const nextValue = isSelected
? selectedValues.filter((item) => item !== optionValue)
: [...selectedValues, optionValue];
onChange(nextValue);
}
return (
<div className="space-y-3">
<h3 className="font-semibold text-sm">{filter.label}</h3>
<div className="flex flex-col gap-y-1.5">
{filter.options.map((option) => (
<label
key={option.value}
className="flex items-center gap-x-2 group/input"
>
<span className="shrink-0 grid grid-cols-1 size-4">
<input
type="checkbox"
className="col-start-1 row-start-1 appearance-none size-4 bg-surface border rounded-sm checked:border-blue-500 checked:bg-blue-500 disabled:bg-surface-content/10 disabled:checked:bg-surface-content/10 disabled:checked:border-surface-content/10 transition-colors duration-200 ease-in-out"
checked={selectedValues.includes(option.value)}
onChange={() => handleChange(option.value)}
/>
<svg
className="col-start-1 row-start-1 size-4 stroke-surface cursor-pointer group-has-checked/input:stroke-accent-foreground group-has-disabled/input:cursor-not-allowed group-has-disabled/input:stroke-surface-content/30"
viewBox="0 0 14 14"
fill="none"
>
<path
className="opacity-0 group-has-checked/input:opacity-100 transition-opacity ease-out"
d="M3 8L6 11L11 3.5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="opacity-0 group-has-checked/input:opacity-100 transition-opacity ease-out"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="grow font-semibold text-xs text-surface-2-content cursor-pointer select-none transition-colors group-has-checked/input:text-surface-content group-has-disabled/input:cursor-default">
{option.label}
</span>
</label>
))}
</div>
</div>
);
}اینجا filter اطلاعاتی مثل Label و Options رو داره.
value هم گزینههایی هست که کاربر انتخاب کرده.
مثلاً:
["apple", "samsung"]و هر بار انتخاب تغییر کنه، onChange مقدار جدید رو به والد برمیگردونه.
حالا فیلتر switch رو میسازیم.
فایل:
components/filters/SwitchFilter.tsxاین کد رو داخلش قرار بدید:
"use client";
import type { FilterProps } from "@/types/filter";
type Props = FilterProps<"switch">;
export function SwitchFilter({ filter, value, onChange }: Props) {
const checked = value ?? false;
return (
<label className="flex items-center justify-between gap-x-2 group/input">
<span className="font-semibold text-xs text-surface-2-content cursor-pointer select-none transition-colors group-has-checked/input:text-surface-content group-has-disabled/input:cursor-default">
{filter.label}
</span>
<span className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer focus:outline-hidden">
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
<span className="absolute inset-0 bg-surface-content/10 border-2 border-transparent rounded-full group-has-checked/input:bg-blue-500 transition-colors duration-200 ease-in-out after:pointer-events-none after:inline-block after:size-4 after:translate-x-0 rtl:after:-translate-x-4 after:transform after:rounded-full after:bg-surface after:ring-0 after:shadow-sm after:transition after:duration-200 after:ease-in-out group-has-checked/input:after:translate-x-4 rtl:group-has-checked/input:after:translate-x-0 dark:group-has-checked/input:after:bg-white"></span>
</span>
</label>
);
}اینجا تایپ اسکریپت میدونه value باید boolean باشه.
چون نوشتیم:
FilterProps<"switch">حالا دو تا کامپوننت داریم، ولی باید مشخص کنیم وقتی API گفت:
type: "checkbox"کدوم کامپوننت باید رندر بشه.
میتونستیم این کار رو با switch انجام بدیم، ولی با زیاد شدن تعداد فیلترها این بخش خیلی سریع تبدیل به یک لیست طولانی از شرطها میشه.
به جای اینکار یک Registry میسازیم.
فایل زیر رو ایجاد کنید:
components/filters/filter-components.tsاین کد رو داخلش قرار بدید:
import type { ComponentType } from "react";
import type {
FilterProps,
FilterType,
} from "@/types/filter";
import { CheckboxFilter } from "./CheckboxFilter";
import { SwitchFilter } from "./SwitchFilter";
type FilterComponentMap = {
[K in FilterType]:
ComponentType<FilterProps<K>>;
};
export const filterComponents = {
checkbox: CheckboxFilter,
switch: SwitchFilter,
} satisfies FilterComponentMap;اینجا بخش مهم satisfies هست.
این تایپ اسکریپت رو مجبور میکنه بررسی کنه که برای تمام تایپهای تعریفشده در FilterConfig یک کامپوننت داریم.
مثلاً اگه بعداً این رو اضافه کنیم:
type: "radio";ولی کامپوننت اون رو به Registry اضافه نکرده باشیم، تایپ اسکریپت خطا میده.
حالا یک کامپوننت میخوایم که از API یک FilterConfig بگیره و کامپوننتهای مناسب رو انتخاب کنه.
فایل:
components/filters/FilterRenderer.tsxاین کد رو داخلش قرار بدید:
import type { ComponentType } from "react";
import type {
FilterConfig,
FilterProps,
FilterValueMap,
FilterType,
} from "@/types/filter";
import { filterComponents } from "./filter-components";
type Props = {
filter: FilterConfig;
value: FilterValueMap[FilterType];
onChange: (value: FilterValueMap[FilterType]) => void;
};
export function FilterRenderer({ filter, value, onChange }: Props) {
const Component = filterComponents[filter.type] as ComponentType<
FilterProps<FilterConfig["type"]>
>;
return <Component filter={filter} value={value} onChange={onChange} />;
}اینجا یک نکتهی تایپ اسکریپت وجود داره.
ما filterComponents رو با filter.type ایندکس میکنیم تا کامپوننت مناسب رو پیدا کنیم. از نظر منطقی، نوع filter و value به هم مرتبط هستن؛ مثلاً اگه نوع فیلتر checkbox باشه، مقدارش باید string[] باشه و اگه switch باشه، باید boolean باشه.
اما تایپ اسکریپت وقتی این ارتباط رو از طریق یک Registry داینامیک دنبال میکنه، همیشه نمیتونه این رابطه رو تا همین نقطه حفظ کنه. برای همین فقط در اینجا، نوع کامپوننت انتخابشده رو با یک Type Assertion مشخص میکنیم:
as ComponentType<
FilterProps<FilterConfig["type"]>
>یعنی به تایپ اسکریپت میگیم: «اینجا میدونیم کامپوننتی که از Registry گرفتیم با قرارداد FilterProps سازگاره.»
مهم اینه که این Assertion فقط در همین نقطه استفاده شده و وارد State یا منطق اصلی برنامه نشده. در نتیجه تایپهای اصلی سیستم همچنان رابطهی بین نوع فیلتر و مقدارش رو حفظ میکنن و نیازی به any یا unknown نداریم.
حالا UI رو داریم.
اما باید جایی داشته باشیم که وضعیت تمام فیلترها رو نگه داره.
نمیخوایم برای هر فیلتر احتمالی یک State جدا داشته باشیم:
const [brand, setBrand] = ...
const [ram, setRam] = ...
const [available, setAvailable] = ...چون Dynamic بودن سیستم دقیقاً یعنی از قبل نمیدونیم چه فیلترهایی قراره وجود داشته باشند.
پس یک State مرکزی میسازیم.
در فایل:
types/filter.tsبعد از FilterValueMap اضافه کنید:
export type FilterValue =
FilterValueMap[FilterType];
export type SelectedFilters =
Record<string, FilterValue>;در نتیجه State ما میتونه چیزی شبیه این باشه:
{
brand: ["apple", "samsung"],
ram: ["8", "12"],
available: true
}اینجا استفاده از Record<string, ...> منطقیه؛ چون کلیدهای واقعی فیلترها از API میان و در زمان نوشتن کد نمیدونیم دقیقاً چه کلیدهایی وجود خواهند داشت.
حالا کامپوننت اصلی فیلترها رو میسازیم.
فایل:
components/filters/ProductFilters.tsxاین کد رو داخلش قرار بدید:
"use client";
import { useState } from "react";
import { FilterRenderer } from "./FilterRenderer";
import type {
FilterConfig,
FilterValue,
SelectedFilters,
} from "@/types/filter";
type Props = {
filters: FilterConfig[];
};
export function ProductFilters({ filters }: Props) {
const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>({});
function handleFilterChange(key: string, value: FilterValue) {
setSelectedFilters((prev) => ({
...prev,
[key]: value,
}));
}
return (
<aside className="divide-y space-y-5">
{filters.map((filter) => (
<div className="pb-3" key={filter.key}>
<FilterRenderer
filter={filter}
value={selectedFilters[filter.key]}
onChange={(value) => handleFilterChange(filter.key, value)}
/>
</div>
))}
</aside>
);
}حالا State کاملاً مستقل از تعداد فیلترهاست.
اگه APIمون برای یه دسته بندی سه فیلتر داشته باشه، سه فیلتر داریم.
اگر فردا پنج فیلتر بده، همین کامپوننت میتونه پنج فیلتر رو نمایش بده.
بدون اینکه لازم باشه State جدیدی تعریف کنیم.
حالا باید این کامپوننت رو به API وصل کنیم.
فایل:
app/products/page.tsxفعلاً برای دریافت فیلترها:
import { ProductFilters } from "@/components/filters/ProductFilters";
import type { FilterConfig } from "@/types/filter";
export default async function Page() {
const filtersResponse = await fetch(
`${process.env.APP_URL}/api/filters`,
{
cache: "no-store",
},
);
const filters =
(await filtersResponse.json()) as FilterConfig[];
return (
<div className="w-full max-w-7xl px-4 mx-auto">
<div className="flex md:flex-nowrap flex-wrap items-start">
<div className="shrink-0 w-72 sticky top-0">
<ProductFilters filters={filters} />
</div>
<div className="grow"></div>
</div>
</div>
);
}و در فایل .env.local:
APP_URL=http://localhost:3000یک نکته درباره نحوه دریافت دادهها:
در پروژههای واقعی معمولاً بخشی جدا برای دسترسی به دادهها داریم که بهش Data Layer میگیم. این لایه مسئول کارهایی مثل خوندن اطلاعات از دیتابیس، ارسال درخواست به سرویسهای دیگه یا آمادهسازی دادهها برای استفاده در بخشهای مختلف برنامه هست.
مثلاً به جای اینکه Page مستقیماً به یک API داخلی درخواست بفرسته، میتونیم تابعی مثل getFilters() داشته باشیم که خودش فیلترها رو از دیتابیس دریافت کنه:
const filters = await getFilters();در این حالت مسیر دریافت داده تقریباً این شکلیه:
Page → getFilters() → Databaseاما توی این مقاله عمداً از یک Route Handler استفاده میکنیم. Route Handler فایلی در مسیر app هست که یک endpoint سمت سرور در اختیارمون قرار میده. در مثال ما، فایل:
app/api/filters/route.tsدرخواستهای مربوط به /api/filters رو مدیریت میکنه و فیلترها رو به شکل JSON برمیگردونه.
بنابراین مسیر دریافت داده در مثال ما این شکلیه:
Page → /api/filters → Route Handlerچون میخوایم APIای که ساختیم واقعاً مصرف بشه و ارتباط بین API و UI رو در یک مثال قابل اجرا ببینیم.
در یک پروژه واقعی ممکنه به جای این مسیر، Page مستقیماً از Data Layer استفاده کنه. اما فعلاً تمرکز ما روی ساخت سیستم Dynamic Filter هست :)
تا اینجا فیلترها در State هستند.
مثلاً:
{
brand: ["apple", "samsung"],
ram: ["8", "12"],
available: true
}اما اگه صفحه رو Refresh یا مجدد بارگذاری کنیم، State از بین میره.
ضمن اینکه نمیتونیم URL فیلترشده رو برای شخص دیگه ای هم بفرستیم.
برای همین وضعیت فیلترها رو با URL هماهنگ میکنیم.
مثلاً:
/products?brand=apple,samsung&ram=8,12&available=trueحالا اگه URL رو داشته باشیم، میتونیم همون وضعیت رو دوباره بسازیم.
منطق تبدیل State به Query String رو بهتر هست از کامپوننت جدا کنیم.
فایل:
lib/filters.tsرو بسازید و داخلش این کد رو قرار بدید:
import type { SelectedFilters } from "@/types/filter";
export function buildFilterQuery(filters: SelectedFilters) {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value === false || (Array.isArray(value) && value.length === 0)) {
return;
}
if (Array.isArray(value)) {
params.set(key, value.join(","));
return;
}
params.set(key, String(value));
});
return params.toString();
}مثلاً:
buildFilterQuery({
brand: ["apple", "samsung"],
ram: ["8", "12"],
available: true,
});خروجی:
brand=apple%2Csamsung&ram=8%2C12&available=trueمرورگر خودش %2C رو به عنوان کاما (,) در URL نمایش میده.
حالا باید وقتی کاربر روی یک فیلتر کلیک میکنه، URL هم تغییر کنه.
فایل:
components/filters/ProductFilters.tsxرو تغییر میدیم.
ابتدا Hookهای مورد نیاز رو اضافه کنید:
import {
usePathname,
useRouter,
} from "next/navigation";و buildFilterQuery رو هم Import کنید:
import {
buildFilterQuery,
} from "@/lib/filters";حالا داخل کامپوننت:
const router = useRouter();
const pathname = usePathname();قرار بدین.
تابع handleFilterChange رو هم به این شکل تغییر بدین:
function handleFilterChange(key: string, value: FilterValue) {
const nextFilters = { ...selectedFilters };
const isEmpty =
value === false || (Array.isArray(value) && value.length === 0);
if (isEmpty) {
delete nextFilters[key];
} else {
nextFilters[key] = value;
}
setSelectedFilters(nextFilters);
const query = buildFilterQuery(nextFilters);
router.replace(query ? `${pathname}?${query}` : pathname);
}حالا مثلاً اگه کاربر از فیلترها برند اپل رو انتخاب کنه:
/products?brand=appleو اگه سامسونگ رو هم انتخاب کنه:
/products?brand=apple,samsungاگه فیلتر RAM رو هم انتخاب کنه:
/products?brand=apple,samsung&ram=8,12و با فعال کردن Switch:
/products?brand=apple,samsung&ram=8,12&available=trueدر واقع State و URL همزمان تغییر میکنند.
اما یک مشکل هنوز داریم.
اگه کاربر مستقیماً وارد این URL بشه:
/products?brand=apple,samsung&ram=8,12&available=trueباید Checkboxها و Switchها هم به همون وضعیت برگردند.
برای این کار باید Query String رو بخونیم و State اولیه رو بسازیم.
چون useSearchParams در Client Component در اختیارمانه، توی همون:
components/filters/ProductFilters.tsxاون رو import میکنیم:
import {
usePathname,
useRouter,
useSearchParams,
} from "next/navigation";حالا داخل کامپوننت:
const searchParams = useSearchParams();برای تبدیل URL به State هم یک تابع میسازیم:
function getInitialFilters(filters: FilterConfig[]): SelectedFilters {
const selected: SelectedFilters = {};
filters.forEach((filter) => {
const value = searchParams.get(filter.key);
if (value === null) {
return;
}
if (filter.type === "checkbox") {
selected[filter.key] = value.split(",");
return;
}
selected[filter.key] = value === "true";
});
return selected;
}حالا بهجای اینکه State رو با یک Object خالی شروع کنیم:
const [selectedFilters, setSelectedFilters] =
useState<SelectedFilters>({});اون رو از URL مقداردهی میکنیم:
const [selectedFilters, setSelectedFilters] =
useState<SelectedFilters>(() =>
getInitialFilters(filters)
);حالا اگه کاربر وارد این URL بشه:
/products?brand=apple,samsung&ram=8,12&available=trueState اولیه تبدیل میشه به:
{
brand: ["apple", "samsung"],
ram: ["8", "12"],
available: true
}و کامپوننتها هم از روی همین State مقدار درست خودشون رو نمایش میدن.
حالا بهترین راه برای اینکه ببینیم این معماری واقعاً Dynamic هست، بیایم یک نوع فیلتر جدید اضافه کنیم :)
فرض کنید میخوایم radio داشته باشیم.
ابتدا در:
types/filter.tsیک تایپ جدید اضافه میکنیم:
export type RadioFilter = {
key: string;
label: string;
type: "radio";
options: FilterOption[];
};و اون رو وارد FilterConfig که Union هست میکنیم:
export type FilterConfig =
| CheckboxFilter
| SwitchFilter
| RadioFilter;حالا FilterType خودش تبدیل میشه به:
"checkbox" | "switch" | "radio"پس FilterValueMap هم باید نوع جدید رو بشناسه:
export type FilterValueMap = {
checkbox: string[];
switch: boolean;
radio: string;
};حالا کامپوننت:
components/filters/RadioFilter.tsxرو میسازیم و کد زیر رو داخلش قرار میدیم:
"use client";
import type { FilterProps } from "@/types/filter";
type Props = FilterProps<"radio">;
export function RadioFilter({ filter, value, onChange }: Props) {
return (
<div className="space-y-3">
<h3 className="font-semibold text-sm">{filter.label}</h3>
<div className="flex flex-col gap-y-1.5">
{filter.options.map((option) => (
<label
key={option.value}
className="flex items-center gap-x-2 group/input"
>
<input
type="radio"
name={filter.key}
className="relative size-4 appearance-none bg-surface border rounded-full cursor-pointer before:absolute before:inset-1 before:bg-surface before:rounded-full not-checked:before:hidden checked:border-blue-500 checked:bg-blue-500 disabled:bg-surface-1 disabled:before:bg-surface-content/10 disabled:cursor-default disabled:checked:bg-surface-content/10 disabled:checked:border-surface-content/10 forced-colors:appearance-auto forced-colors:before:hidden"
value={option.value}
checked={value === option.value}
onChange={() => onChange(option.value)}
/>
<span className="grow font-semibold text-xs text-surface-2-content cursor-pointer select-none transition-colors group-has-checked/input:text-surface-content group-has-disabled/input:cursor-default group-has-disabled/input:text-surface-2-content/80 group-has-disabled/input:group-has-checked/input:text-surface-content/75">
{option.label}
</span>
</label>
))}
</div>
</div>
);
}حالا باید اون رو در Registry اضافه کنیم.
فایل:
components/filters/filter-components.tsرو باز کنید و RadioFilter رو داخلش import کنید:
import { RadioFilter } from "./RadioFilter";و
export const filterComponents = {
checkbox: CheckboxFilter,
switch: SwitchFilter,
radio: RadioFilter,
} satisfies FilterComponentMap;نکته جالب اینجاست که وقتی radio رو به FilterConfig اضافه کردیم، تایپ اسکریپت خودش Registry رو مجبور میکنه که این نوع جدید رو هم پوشش بده.
حالا API میتونه چنین فیلتر جدیدی برگردونه:
{
"key": "condition",
"label": "وضعیت کالا",
"type": "radio",
"options": [
{
"value": "new",
"label": "نو"
},
{
"value": "used",
"label": "کارکرده"
}
]
}و سیستم بدون تغییر در ProductFilters میتونه اون رو دریافت و Render کنه.
API تعریف فیلترها رو بهمون میده:
{
"key": "brand",
"label": "برند",
"type": "checkbox"
}type مشخص میکند چه کامپوننتی باید Render بشه.
Registry این ارتباط رو نگه میداره:
{
checkbox: CheckboxFilter,
switch: SwitchFilter,
radio: RadioFilter,
}State انتخابهای کاربر رو نگه میداره:
{
brand: ["apple", "samsung"],
ram: ["8", "12"],
available: true
}و در نهایت همین State به URL تبدیل میشه:
/products?brand=apple,samsung&ram=8,12&available=trueاگه کاربر صفحه رو Refresh کنه، دوباره URL خونده میشه و State از روی اون ساخته میشه.
پس صفحه فقط تعریف فیلترها رو دریافت میکنه و سیستم بر اساس اون UI رو میسازه.
خب، تموم شد! 👋
اگه جایی از مقاله براتون گنگ بود، سوالی داشتید یا فکر میکنید یه بخشی میتونست بهتر توضیح داده بشه، توی بخش نظرات برای من و بقیه دوستان بنویسید..
فعلا تا بعد ❤️👋
دیدگاه و یا پرسش خود را برای ما ارسال کنید.
هنوز دیدگاه یا پرسشی ایجاد نشده است :/
تجربهها، دیدگاهها و نکات الهامبخشی که با شما به اشتراک میگذاریم.