پنج‌شنبه, 19 شهریور 1405

پیاده سازی Dynamic Filter

...

” توی این مقاله قدم‌به‌قدم یک سیستم 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 رو بسازیم.

در یک پروژه واقعی، این اطلاعات احتمالاً از دیتابیس یا یک سرویس دیگه میان و با توجه به دسته بندی متفاوت‌اند.

اما برای اینکه شما هم قدم به قدم با مقاله پیش برید، فعلاً یک 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.


ساخت CheckboxFilter

حالا 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 مقدار جدید رو به والد برمی‌گردونه.


ساخت SwitchFilter

حالا فیلتر 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">

ساخت Registry برای فیلترها

حالا دو تا کامپوننت داریم، ولی باید مشخص کنیم وقتی 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 اضافه نکرده باشیم، تایپ اسکریپت خطا میده.


ساخت FilterRenderer

حالا یک کامپوننت میخوایم که از 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 نداریم.


ساخت State مرکزی فیلترها

حالا 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 میان و در زمان نوشتن کد نمیدونیم دقیقاً چه کلیدهایی وجود خواهند داشت.


ساخت ProductFilters

حالا کامپوننت اصلی فیلترها رو می‌سازیم.

فایل:

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

حالا باید این کامپوننت رو به 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 هست :)


اتصال فیلترها به URL

تا اینجا فیلترها در State هستند.

مثلاً:

{
  brand: ["apple", "samsung"],
  ram: ["8", "12"],
  available: true
}

اما اگه صفحه رو Refresh یا مجدد بارگذاری کنیم، State از بین میره.

ضمن اینکه نمی‌تونیم URL فیلترشده رو برای شخص دیگه ای هم بفرستیم.

برای همین وضعیت فیلترها رو با URL هماهنگ می‌کنیم.

مثلاً:

/products?brand=apple,samsung&ram=8,12&available=true

حالا اگه URL رو داشته باشیم، می‌تونیم همون وضعیت رو دوباره بسازیم.


ساخت Query Builder

منطق تبدیل 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 نمایش میده.


اضافه کردن فیلتر به Route

حالا باید وقتی کاربر روی یک فیلتر کلیک می‌کنه، 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

اما یک مشکل هنوز داریم.

اگه کاربر مستقیماً وارد این 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=true

State اولیه تبدیل میشه به:

{
  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 رو می‌سازه.

خب، تموم شد! 👋

اگه جایی از مقاله براتون گنگ بود، سوالی داشتید یا فکر می‌کنید یه بخشی می‌تونست بهتر توضیح داده بشه، توی بخش نظرات برای من و بقیه دوستان بنویسید..

فعلا تا بعد ❤️👋

ارسال دیدگاه

دیدگاه و یا پرسش خود را برای ما ارسال کنید.

وارد شوید

برای ارسال دیدگاه یا پرسش خود ابتدا وارد سایت شوید

ورود یا ثبت نام

دیدگاه کاربران

هنوز دیدگاه یا پرسشی ایجاد نشده است :/

تازه‌ترین نوشته‌ها

دیدن همه

تجربه‌ها، دیدگاه‌ها و نکات الهام‌بخشی که با شما به اشتراک می‌گذاریم.

دنیای توسعه
چهارشنبه, 21 مرداد 1405

Git Flow

لاراول
چهارشنبه, 14 مرداد 1405

الگوی Action در لاراول