صفحه جدول داده

الگوی ساخت صفحه لیست با جستجو، فیلتر، مرتب‌سازی، و صفحه‌بندی

معرفی

صفحه جدول داده رایج‌ترین نوع صفحه در اپلیکیشن‌های SaaS است. لیست اینفلوئنسرها، کمپین‌ها، گزارش‌ها، و هر مجموعه داده‌ای که کاربر باید مرور، جستجو، فیلتر، و مرتب‌سازی کند در این قالب قرار می‌گیرد. فهرست پست‌ها، نظرها و حساب‌ها جدول نیست: EntityCollection زیر همین PageToolbar است (بلاک جست‌وجوی پست).

صفحهٔ فهرست: ListPage

صفحهٔ جدول داده ListPage است: نوارابزار، جای اقدام اصلی، حالت‌های بارگذاری و خالی و خطا و صفحه‌بندی را قالب تعیین می‌کند و DataTable بی‌قاب فرزند آن است. نمونه‌های پایین جزئیات ستون‌ها، سلول‌ها و مرتب‌سازی جدول را نشان می‌دهند.

این الگو ترکیب کامپوننت‌های زیر را نشان می‌دهد:

  • ساختار صفحه: ListPage — عرض، سرِ صفحه، نوارابزار، جای اقدام‌ها، حالت‌ها و صفحه‌بندی
  • نوار ابزار: SearchInput، DataTableFacetedFilter و DateRangePicker در جایگاه‌های search و filters
  • جدول: DataTable با ستون‌های قابل مرتب‌سازی و کامپوننت‌های دامنه‌ای، بی‌قاب و فرزند مستقیم قالب
  • صفحه‌بندی: pagination خود قالب، با برچسب محدوده («1 تا 25 از 60»)
  • حالت‌های صفحه: بارگذاری، خطا، خالی و «نتیجه‌ای یافت نشد» با state={pageState({ … })} و filtered

نمونه بصری

نسخه‌ی کارکننده و کپی‌بردار این الگو صفحهٔ «منشن‌ها» در بلاک قالب شروع است (همان apps/starter/screens/mentions.tsx): داخل ProductFrame، یک ListPage با جست‌وجو، فیلترها، DataTable، صفحه‌بندی و حالت‌های بارگذاری، خطا و خالی — در اندازه‌های مختلف صفحه و هر دو تم قابل بررسی. فهرست پست‌ها (EntityCollection از Post) با همین نوارابزار: بلاک جست‌وجوی پست.

بلوک آماده: قالب شروع (Starter)

کد و نمای کامل
رضا کریمی234,0002.8٪
سارا احمدی87,3003.1٪
امیر رضایی45,8003.5٪
علی محمدی12,5004.2٪
مریم حسینی5,2006.7٪

ساختار صفحه

صفحه یک ListPage است و فقط جایگاه‌هایش را پر می‌کند؛ هر جایگاه همان‌طور که جدول «هر جایگاه، یک پاسخ» می‌گوید:

'use client'

import * as React from 'react'
import Link from 'next/link'
import {
  Button,
  DataTable,
  DataTableExportButton,
  DataTableFacetedFilter,
  DateRangePicker,
  SearchInput,
  useAsync,
  useDebounce,
  type DataTableSort,
  type DateRange,
} from '@partodata/ui'
import { Icons } from '@partodata/ui/icons'
import { ListPage, pageState } from '@partodata/ui/templates'
// ستون‌ها (پایین‌تر)، گزینه‌های پلتفرم و درخواست سرور، از کد خود محصول
import { PLATFORMS, columns, fetchInfluencers, type InfluencerPage, type Platform } from '@/lib/influencers'

const PAGE_SIZE = 25

export function Influencers() {
  const [q, setQ] = React.useState('')
  // Typing waits 300ms before it searches; clearing the search applies at once.
  const debounced = useDebounce(q, 300)
  const search = q === '' ? '' : debounced
  const [platforms, setPlatforms] = React.useState<Platform[]>([])
  const [range, setRange] = React.useState<DateRange | undefined>()
  const [sortState, setSortState] = React.useState<{ column: string | null; direction: 'asc' | 'desc' | null }>({
    column: null,
    direction: null,
  })
  const [page, setPage] = React.useState(1)
  const { data, isLoading, error, run } = useAsync<InfluencerPage>()
  const load = React.useCallback(
    () => run(() => fetchInfluencers({ q: search, platforms, range, sort: sortState, page, pageSize: PAGE_SIZE })),
    [run, search, platforms, range, sortState, page]
  )
  React.useEffect(() => {
    load()
  }, [load])

  // Every search, filter or sort change starts again from the first page.
  const filterBy =
    <T,>(set: (value: T) => void) =>
    (value: T) => {
      set(value)
      setPage(1)
    }
  const clear = () => {
    setQ('')
    setPlatforms([])
    setRange(undefined)
    setPage(1)
  }
  const sort: DataTableSort = {
    ...sortState,
    onSort: (column, direction) => filterBy(setSortState)({ column, direction }),
  }
  const rows = data?.items ?? []

  return (
    <ListPage
      title="اینفلوئنسرها"
      description="اینفلوئنسرهای پایش‌شده در همهٔ پلتفرم‌ها"
      search={
        <SearchInput
          placeholder="جست‌وجو در اینفلوئنسرها"
          aria-label="جست‌وجو در اینفلوئنسرها"
          value={q}
          onChange={(e) => filterBy(setQ)(e.target.value)}
          onClear={() => filterBy(setQ)('')}
        />
      }
      filters={
        <>
          <DataTableFacetedFilter
            title="پلتفرم"
            options={PLATFORMS}
            selected={platforms}
            onSelectedChange={filterBy(setPlatforms)}
          />
          <DateRangePicker value={range} onChange={filterBy(setRange)} placeholder="بازهٔ آخرین پست" />
        </>
      }
      filtered={q !== '' || platforms.length > 0 || range !== undefined}
      onClearFilters={clear}
      secondaryActions={
        <DataTableExportButton columns={columns} data={rows} filename="influencers.csv" label="خروجی CSV" />
      }
      primaryAction={
        <Button asChild iconStart={<Icons.plus />}>
          <Link href="/influencers/new">افزودن اینفلوئنسر</Link>
        </Button>
      }
      state={pageState({
        data: data?.items,
        isLoading,
        error,
        onRetry: load,
        emptyCopy: { title: 'هنوز اینفلوئنسری ثبت نشده است' },
      })}
      pagination={{
        currentPage: page,
        totalPages: Math.ceil((data?.total ?? 0) / PAGE_SIZE),
        onPageChange: setPage,
        totalRows: data?.total ?? 0,
        pageSize: PAGE_SIZE,
      }}
    >
      <DataTable columns={columns} data={rows} sort={sort} />
    </ListPage>
  )
}

جای اقدام اصلی (انتهای نوارابزار، چون صفحه جست‌وجو و فیلتر دارد)، اندازهٔ کنترل‌ها و عرضشان، فاصله‌ها، شکستن ردیف روی صفحهٔ باریک و ردیف صفحه‌بندی را قالب تعیین می‌کند؛ ردیف را با div و flex نسازید و به کنترل‌ها و جدول size یا کلاس عرض ندهید. جست‌وجو، فیلترها، مرتب‌سازی و شمارهٔ صفحه وضعیت خود کامپوننت‌اند و با نشانی صفحه همگام نمی‌شوند.


ستون‌ها با کامپوننت‌های دامنه‌ای

هر ستون DataTable یک cell دارد و برای خروجی CSV یک exportValue با مقدار ساده (برچسب، عدد): نام ردیف پیوندی به صفحهٔ آن، PlatformMark برای پلتفرم (بی size)، formatNumber برای عدد با align: 'end'، EngagementRate برای نرخ تعامل، Badge برای وضعیت، و اقدام‌های ردیف در ستون آخر — یک DropdownMenu پشت دکمهٔ آیکونی ghost:

import Link from 'next/link'
import { Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, EngagementRate, formatNumber, toast, type ExportableColumn } from '@partodata/ui'
import { PlatformMark } from '@partodata/ui/social'
import { Icons } from '@partodata/ui/icons'

export type Platform = 'instagram' | 'twitter' | 'tiktok' | 'youtube'

interface Influencer {
  id: string
  name: string
  platform: Platform
  followers: number
  /** نسبت: 0.042 یعنی 4.2٪ */
  engagementRate: number
  status: 'active' | 'pending' | 'inactive'
  profileUrl: string
}

export const PLATFORMS: { value: Platform; label: string }[] = [
  { value: 'instagram', label: 'اینستاگرام' },
  { value: 'twitter', label: 'ایکس' },
  { value: 'tiktok', label: 'تیک‌تاک' },
  { value: 'youtube', label: 'یوتیوب' },
]
const labelOf = (options: { value: string; label: string }[], value: string) =>
  options.find((option) => option.value === value)?.label ?? value

const STATUS: Record<Influencer['status'], { label: string; variant: 'default' | 'secondary' | 'destructive' }> = {
  active: { label: 'فعال', variant: 'default' },
  pending: { label: 'در انتظار', variant: 'secondary' },
  inactive: { label: 'غیرفعال', variant: 'destructive' },
}

async function copyProfileLink(url: string) {
  try {
    await navigator.clipboard.writeText(url)
    toast.success('پیوند پروفایل کپی شد')
  } catch {
    toast.error('کپی پیوند ممکن نشد')
  }
}

export const columns: ExportableColumn<Influencer>[] = [
  {
    id: 'name',
    header: 'نام',
    sortable: true,
    // The row's name: a link to its own page.
    cell: (row) => (
      <Link href={`/influencers/${row.id}`} prefetch={false}>
        {row.name}
      </Link>
    ),
    exportValue: (row) => row.name,
  },
  {
    id: 'platform',
    header: 'پلتفرم',
    cell: (row) => <PlatformMark source={row.platform} showLabel />,
    exportValue: (row) => labelOf(PLATFORMS, row.platform),
  },
  {
    id: 'followers',
    header: 'دنبال‌کننده‌ها',
    sortable: true,
    align: 'end',
    cell: (row) => formatNumber(row.followers),
    exportValue: (row) => row.followers,
  },
  {
    id: 'engagementRate',
    header: 'نرخ تعامل',
    sortable: true,
    cell: (row) => <EngagementRate display="bar" currentRate={row.engagementRate} followers={row.followers} locale="fa" />,
    exportValue: (row) => row.engagementRate,
  },
  {
    id: 'status',
    header: 'وضعیت',
    cell: (row) => <Badge variant={STATUS[row.status].variant}>{STATUS[row.status].label}</Badge>,
    exportValue: (row) => STATUS[row.status].label,
  },
  // A row's actions: the last column — its page first, then the row's own actions.
  {
    id: 'actions',
    header: <span className="sr-only">عملیات</span>,
    align: 'end',
    cell: (row) => (
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" icon={<Icons.moreHorizontal />} aria-label={`عملیات ${row.name}`} />
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem asChild>
            <Link href={`/influencers/${row.id}`}>مشاهدهٔ جزئیات</Link>
          </DropdownMenuItem>
          <DropdownMenuItem onSelect={() => copyProfileLink(row.profileUrl)}>کپی پیوند پروفایل</DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    ),
    exportValue: () => null,
  },
]

مرتب‌سازی سرور-محور است: sort جدول ستون و جهت را به صفحه می‌دهد و صفحه آن را به درخواست می‌فرستد؛ جدول خودش ردیف‌های یک صفحه را مرتب نمی‌کند. aria-sort سرستون‌ها را DataTable خودش می‌گذارد.


حالت‌ها

بارگذاری، خطا و خالی را خودتان نسازید: state={pageState({ data: data?.items, isLoading, error, onRetry, emptyCopy })} آن‌ها را به‌جای جدول می‌گذارد و سرِ صفحه و نوارابزار در همهٔ حالت‌ها می‌مانند:

  • تا چیزی بارگذاری نشده، اسکلتی به شکل جدول؛ ردیف‌هایی که روی صفحه‌اند تا رسیدن صفحهٔ بعد یا نتیجهٔ فیلتر تازه می‌مانند.
  • درخواستی که شکست خورده ErrorState با «تلاش مجدد» است، حتی اگر دادهٔ قبلی روی صفحه بوده باشد.
  • فهرست خالی وقتی filtered است «نتیجه‌ای یافت نشد» با «پاک کردن فیلترها» است (متن خود سیستم طراحی)؛ وگرنه عنوان emptyCopy که نام آنچه نیست را می‌برد («هنوز اینفلوئنسری ثبت نشده است») — بی action: اقدام اصلی صفحه روی صفحه هست.

Empty یا Skeleton را داخل جدول نگذارید و emptyState و isLoading خود DataTable را در صفحهٔ فهرست به کار نبرید؛ ترتیب و قاعده را PageState تعیین می‌کند.


صفحه‌بندی

صفحه‌بندی سمت سرور است و جایگاهش pagination خود ListPage: هر پنج فیلد (currentPage، totalPages، onPageChange، totalRows و pageSize) را بدهید؛ قالب ردیف صفحه‌بندی را زیر جدول می‌گذارد، برچسب محدوده («26 تا 50 از 60») در ابتدای خط و شماره‌ها در انتهای آن. Pagination یا شمارش نتیجه‌ها را خودتان کنار جدول نچینید. هر تغییر جست‌وجو، فیلتر یا مرتب‌سازی صفحه را به 1 برمی‌گرداند (filterBy بالا) تا کاربر در صفحه‌ای نماند که دیگر نتیجه‌ای ندارد.


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

نمایش تعداد نتایج

تعداد نتایج برچسب محدودهٔ ردیف صفحه‌بندی است («1 تا 25 از 60») و قالب آن را در همهٔ صفحه‌های فهرست نشان می‌دهد؛ شمارش جداگانه‌ای نسازید.

جستجو با تاخیر

جست‌وجو با تاخیر (debounce) به سرور می‌رود تا با هر کلید فشرده‌شده درخواستی ارسال نشود؛ پاک کردن جست‌وجو فوراً اعمال می‌شود. هوک useDebounce همین کار را می‌کند، و درخواست search را می‌گیرد، نه q:

const [q, setQ] = React.useState('')
const debounced = useDebounce(q, 300)
const search = q === '' ? '' : debounced

دسترسی‌پذیری مرتب‌سازی

ستون قابل مرتب‌سازی sortable: true دارد و DataTable سرستونش را دکمهٔ مرتب‌سازی با aria-sort می‌کند؛ TableSortHeader و aria-sort را خودتان نسازید.

محدود کردن ستون‌ها

تعداد ستون‌های جدول را تا 8 ستون نگه دارید تا در عرض پیش‌فرض صفحهٔ فهرست (1200) جا شود؛ فقط جدول بیش از 8 ستون width="wide" می‌گیرد. اگر اطلاعات بیشتری لازم است، از صفحهٔ جزئیات یا DropdownMenu برای اقدامات استفاده کنید.

فیلترهای فعال

فیلترهای نوارابزار مقدارشان را روی دکمهٔ خودشان نشان می‌دهند (DataTableFacetedFilter مقدارهای انتخاب‌شده را، و DateRangePicker بازه را)، پس برایشان چیپ (activeFilters) نسازید. activeFilters فقط برای فیلترهایی است که در نوارابزار دیده نمی‌شوند: از یک پنل فیلتر، کلیک روی نمودار یا یک پیوند. دکمهٔ «پاک کردن فیلترها» را قالب وقتی filtered است نشان می‌دهد.

جای اقدام اصلی

اقدام اصلی (مانند «افزودن اینفلوئنسر») primaryAction قالب است و قالب جایش را تعیین می‌کند: صفحه‌ای که جست‌وجو یا فیلتر دارد، در انتهای نوارابزار؛ صفحهٔ بی آن‌ها، در انتهای سرِ صفحه. اقدامی که به صفحهٔ دیگری می‌رود پیوند است (<Button asChild><Link href="…">…</Link></Button>). در هر صفحه فقط یک اقدام اصلی هست؛ اقدام‌های دیگر در secondaryActions، همه variant="default" (خروجی جدول DataTableExportButton).


دام‌های رایج

اشتباهات پرتکراری که در صفحات جدول دیده می‌شوند — هر مورد شامل اشتباه، دلیل، و الگوی درست است.

مرتب‌سازی سمت کلاینت روی دادهٔ صفحه‌بندی‌شدهٔ سرور

اشتباه: صفحه‌بندی سمت سرور است، اما مرتب‌سازی با useMemo فقط روی ردیف‌های بارگذاری‌شدهٔ صفحهٔ فعلی اجرا می‌شود.

// ❌ غلط — داده از سرور صفحه‌بندی شده، اما فقط 10 ردیف صفحهٔ فعلی مرتب می‌شود
const sorted = useMemo(() => {
  const dir = sortDirection === 'asc' ? 1 : -1
  return [...pageRows].sort((a, b) => (a.followers - b.followers) * dir)
}, [pageRows, sortDirection])

// ✅ درست — ستون و جهت مرتب‌سازی به درخواست می‌رود و سرور صفحهٔ مرتب‌شده را برمی‌گرداند
const load = React.useCallback(
  () => run(() => fetchInfluencers({ q: search, platforms, range, sort: sortState, page, pageSize: PAGE_SIZE })),
  [run, search, platforms, range, sortState, page]
)

چرا دردسرساز است: «بیشترین دنبال‌کننده» به بزرگ‌ترین مقدارِ همان صفحه تبدیل می‌شود، نه کل مجموعه داده؛ ترتیب با هر تغییر صفحه عوض می‌شود و کاربر به نتایج گمراه‌کننده می‌رسد. مرتب‌سازی سمت کلاینت (مانند نمونهٔ ابتدای همین صفحه) فقط زمانی درست است که کل داده در کلاینت باشد. کامپوننت آمادهٔ DataTable سیستم طراحی نیز به همین دلیل سرور-محور طراحی شده و totalCount را از سرور دریافت می‌کند — از روی داده‌های یک صفحه نمی‌توان آن را استنتاج کرد.

خواص فیزیکی CSS در ستون‌های جدول

اشتباه: تراز کردن ستون عددی با کلاس‌های فیزیکی مانند text-left یا فاصله‌گذاری با pl-*، معمولاً با کپی‌کردن نمونه‌های LTR.

// ❌ غلط — در پیش‌نمایش RTL درست به نظر می‌رسد اما جهت‌محور است
<TableCell className="text-left pl-6">{influencer.followers.toLocaleString('en-US')}</TableCell>

// ✅ درست — Logical Properties در هر دو جهت درست کار می‌کند
<TableCell className="text-end pe-6">{influencer.followers.toLocaleString('en-US')}</TableCell>

چرا دردسرساز است: این دام پنهان است چون در صفحهٔ RTL ظاهر هر دو یکسان است و از بازبینی بصری رد می‌شود. اما سرستون‌های TableHead با text-start (منطقی) تراز می‌شوند؛ به محض این‌که همان جدول در بستری LTR رندر شود، سلول‌های فیزیکی خلاف جهت سرستون می‌روند و ستون دوپاره می‌شود. سیستم طراحی RTL-first است و همه‌جا از Logical Properties استفاده می‌کند: ms به‌جای ml، pe به‌جای pr، text-start/text-end به‌جای text-left/text-right.

رنگ وضعیت hardcode در سلول‌ها

اشتباه: نمایش وضعیت ردیف با رنگ مستقیم Tailwind یا مقدار hex به‌جای واریانت‌های Badge یا توکن‌های معنایی.

// ❌ غلط — سبزِ hardcode با تم پیش‌فرض تیره هماهنگ نیست
<TableCell>
  <span className="text-green-600">فعال</span>
</TableCell>

// ✅ درست — واریانت Badge از توکن‌های تم رنگ می‌گیرد
<TableCell>
  <Badge variant={STATUS_MAP[influencer.status].variant}>{STATUS_MAP[influencer.status].label}</Badge>
</TableCell>

چرا دردسرساز است: سیستم طراحی dark-first است — تم پایهٔ :root تیره است و رنگی که روی پس‌زمینهٔ روشن انتخاب شده، در تم تیره کنتراست کافی ندارد؛ با تغییر تم به روشن هم به‌روزرسانی نمی‌شود. توکن‌های معنایی (text-destructive، bg-brand/10، واریانت‌های Badge) در هر دو تم مقدار درست را می‌گیرند. قانون no-hardcoded-colors در ESLint خود سیستم طراحی نیز همین را اجبار می‌کند — در کد مصرف‌کننده هم آن را رعایت کنید.

تبدیل ارقام در جاوااسکریپت

اشتباه: «فارسی‌کردن» اعداد ستون‌ها در کد، یا رها کردن مقدار خام بدون جداکننده.

import { formatNumber } from '@partodata/ui'

// ❌ غلط — کدپوینت واقعی U+06Fx می‌سازد، و بدون جداکننده هم خوانا نیست
<TableCell>{influencer.followers.toLocaleString('fa-IR')}</TableCell>
<TableCell>{influencer.followers}</TableCell>

// ✅ درست — کدپوینت لاتین با جداکنندهٔ هزار؛ فونت آن را «1,234,567» نشان می‌دهد
<TableCell>{formatNumber(influencer.followers)}</TableCell>

چرا دردسرساز است: ارقام فارسی از ویژگی ss01 فونت می‌آیند، نه از جاوااسکریپت. تبدیل در کد همان ظاهر را می‌دهد ولی چهار چیز را می‌شکند: کاربر با تایپ 1234 در Ctrl+F سلول را پیدا نمی‌کند، کپی به Excel عدد نیست، رفتار صفحه‌خوان‌ها روی U+06Fx یکدست نیست، و مرتب‌سازی رشته‌ای «10» را قبل از «9» می‌گذارد. جزئیات و اثبات در فارسی‌محور بودن.

لحن محاوره‌ای در متن‌های جدول

اشتباه: نوشتن عنوان حالت خالی یا برچسب‌های صفحه با فارسی محاوره‌ای.

// ❌ غلط — لحن محاوره‌ای
emptyCopy: {
  title: 'هنوز کسی اینجا نیست! یکی اضافه کن.'
}

// ✅ درست — فارسی رسمی، و فقط عنوانی که نام آنچه نیست را می‌برد
emptyCopy: {
  title: 'هنوز اینفلوئنسری ثبت نشده است'
}

متن «نتیجه‌ای یافت نشد» (وقتی جست‌وجو یا فیلتری فعال است) متن خود سیستم طراحی است و آن را نمی‌نویسید.

چرا دردسرساز است: لحن سیستم طراحی در همهٔ محصولات پرتو فارسی رسمی است («تغییر دهید» نه «عوض کن»). حالت خالی معمولاً آخرین متنی است که نوشته می‌شود و اولین جایی است که لحن محاوره‌ای به آن نشت می‌کند؛ ناهماهنگی لحن در یک صفحه، اعتماد کاربر سازمانی را کم می‌کند. راهنمای کامل در صفحهٔ محتوا و لحن آمده است.


صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • قالب صفحهٔ فهرست (ListPage) — قالبی که این الگو روی آن ساخته شده است.
  • جدول داده (DataTable) — جدول سرور-محور فرزند قالب: مرتب‌سازی، انتخاب ردیف، ستون‌های سنجاق‌شده؛ در صفحهٔ فهرست بی pagination، emptyState و isLoading خودش.
  • جدول با مرتب‌سازی — اگر فقط جدول قابل مرتب‌سازی لازم دارید نه کل صفحهٔ لیست، جزئیات کامل الگوی aria-sort در این صفحه است.
  • حالت‌های خالی — برای طراحی متن و اقدام حالت صفر نتیجه، فراتر از نمونهٔ کوتاه این صفحه.
  • الگوهای بارگذاری — برای انتخاب بین Skeleton و Spinner و بارگذاری تدریجی در جدول‌های سنگین.