اقدام بسته‌شده (GatedAction)

اقدامی که کاربر اکنون نمی‌تواند انجامش دهد — همان‌جا در جایگاهش، غیرفعال، با دلیلش؛ یک پاسخ به‌جای پنهان کردن یا دکمهٔ غیرفعال بی‌دلیل

معرفی

GatedAction پاسخ سیستم طراحی به عدم دسترسی در سطح اقدام است: اقدامی که کاربر اکنون نمی‌تواند انجامش دهد — اجازه‌اش را ندارد، سهمیه‌اش تمام شده، یا وضعیت خود صفحه اجازه نمی‌دهد (ارسال بولتنی که هنوز بخشی ندارد) — پنهان نمی‌شود. در جایگاه خودش می‌ماند، غیرفعال، و دلیلش را می‌گوید:

  • با allowed درست، همان Button بی هیچ تغییری رندر می‌شود؛
  • با allowed نادرست، همان Button با aria-disabled (هنوز با صفحه‌کلید در دسترس، تا کاربر صفحه‌کلید و صفحه‌خوان هم به آن برسد و دلیل را به‌عنوان توضیح دکمه بشنود)، کم‌رنگ، بی‌کلیک و بی‌پیوند؛ و reason در یک راهنمای ابزار با hover، فوکوس و لمس (صفحهٔ لمسی hover ندارد).

این همان الگویی است که گزارش ممیزی در قرارداد صفحه آورده (دکمهٔ غیرفعال با راهنمای علت) و در هر جایگاه اقدام قالب‌های صفحه کار می‌کند: primaryAction، secondaryActions، actions یک بخش، و bulkActions انتخاب یک ListPage.

چه زمانی استفاده کنیم:

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

چه زمانی استفاده نکنیم:

  • کاربر اجازهٔ دیدن کل صفحه را ندارد: UtilityPage نوع 403، داخل قاب.
  • اقدام در حال اجراست: isLoading خود Button، نه disabled.
  • دکمهٔ ارسال فرمی که هنوز تغییری ندارد: SettingsSection آن را خودش غیرفعال می‌کند.

استفاده

GatedAction از @partodata/ui/templates می‌آید، کنار قالب‌ها. فرزندش یک Button است که همان‌جا نوشته شده، دقیقاً همان‌طور که جایگاهش می‌خواهد (اقدام اصلی بی variant، اقدام ثانوی variant="default")، یا خروجی جدول، DataTableExportButton. هیچ چیز دیگری فرزندش نمی‌شود (نه ConfirmDialog، نه کامپوننتی از خود برنامه که Button را در بر دارد): GatedAction فقط همان دکمه را می‌تواند غیرفعال کند و هر چیز دیگری را، وقتی اجازه نیست، بی‌اثر و بی‌دلیل رندر می‌کند (در حالت توسعه هشدار می‌دهد و قاعدهٔ ESLint هم نشانش می‌دهد):

'use client'
import { Button } from '@partodata/ui'
import { Icons } from '@partodata/ui/icons'
import { GatedAction, ListPage, pageState } from '@partodata/ui/templates'

type Alert = { id: string; name: string }

export function AlertsScreen({ alerts, canCreate }: { alerts: Alert[]; canCreate: boolean }) {
  return (
    <ListPage
      title="هشدارها"
      primaryAction={
        <GatedAction allowed={canCreate} reason="ساخت هشدار فقط برای مدیران فضای کار ممکن است">
          <Button onClick={() => {}} iconStart={<Icons.plus />}>
            ساخت هشدار
          </Button>
        </GatedAction>
      }
      state={pageState({
        data: alerts,
        isLoading: false,
        error: null,
        onRetry: () => {},
        emptyCopy: { title: 'هنوز هشداری ساخته نشده است' },
      })}
    >
      <ul>
        {alerts.map((alert) => (
          <li key={alert.id}>{alert.name}</li>
        ))}
      </ul>
    </ListPage>
  )
}

reason یک جملهٔ رسمی است که می‌گوید چرا و چه کسی می‌تواند («… فقط برای مدیران فضای کار ممکن است»)، نه «دسترسی ندارید». allowed خود شرط است (canCreate، permissions.alerts.create، can('create', 'Alert')).

حالت‌ها و انواع

پیوند

اقدامی که به صفحهٔ دیگری می‌رود (<Button asChild><Link href="…">…</Link></Button>) وقتی اجازه نیست، دکمه‌ای معمولی و غیرفعال با همان برچسب می‌شود: پیوند غیرفعال هنوز با کلیک وسط یا منوی راست‌کلیک باز می‌شد.

در فرم

GatedAction دور دکمهٔ type="submit" فرم را هرگز ارسال نمی‌کند.

اقدامی که با پنجرهٔ تأیید انجام می‌شود

اقدامی مثل حذف که ConfirmDialog تأییدش می‌کند: GatedAction ماشهٔ پنجره است (trigger)، نه پنجره درون GatedAction. وقتی اجازه نیست، پنجره هرگز باز نمی‌شود و کلیک دلیل را نشان می‌دهد؛ وقتی اجازه هست، دکمه پنجره را باز می‌کند و پس از بسته شدن آن فوکوس به همان دکمه برمی‌گردد. اقدام گروهی ListPage هم همین‌طور نوشته می‌شود:

'use client'
import { Button, ConfirmDialog } from '@partodata/ui'
import { GatedAction } from '@partodata/ui/templates'

export function DeleteAlertAction({ canDelete, onDelete }: { canDelete: boolean; onDelete: () => Promise<void> }) {
  return (
    <ConfirmDialog
      trigger={
        <GatedAction allowed={canDelete} reason="حذف هشدار فقط برای مدیران فضای کار ممکن است">
          <Button variant="default">حذف</Button>
        </GatedAction>
      }
      title="این هشدار حذف شود؟"
      description="هشدار و تاریخچهٔ آن برای همیشه حذف می‌شوند."
      variant="destructive"
      confirmLabel="حذف"
      onConfirm={onDelete}
    />
  )
}

خروجی جدول

خروجی جدول (DataTableExportButton) با data خالی (هنگام بارگذاری، «نتیجه‌ای یافت نشد» یا خطا) روی صفحهٔ قالب خودکار بسته می‌شود: قالب آن را در همین GatedAction با دلیل خودش می‌گذارد («خروجی وقتی ممکن است که فهرست دست‌کم یک ردیف داشته باشد») و منویش باز نمی‌شود؛ وقتی ردیف‌ها می‌رسند همان دکمه فعال می‌شود و فوکوس رویش می‌ماند. پس در secondaryActions همیشه بی هیچ شرطی نوشته می‌شود — نه GatedAction روی تعداد ردیف‌ها، نه disabled، نه rows.length > 0 && … (قاعدهٔ parto/page-primary-action هر سه را نشان می‌دهد).

GatedAction دور خروجی فقط برای اجازه (یا سهمیه) است: وقتی اجازه نیست، همان دکمه غیرفعال با دلیل همان GatedAction است و منویش با کلیک، Enter یا پیکان پایین باز نمی‌شود (خروجی CSV در مرورگر ساخته می‌شود و سرور جلویش را نمی‌گیرد):

'use client'
import { DataTableExportButton, type DataTableColumn } from '@partodata/ui'
import { GatedAction } from '@partodata/ui/templates'

type Mention = { id: string; author: string }

export function MentionsExport({
  rows,
  columns,
  canExport,
}: {
  rows: Mention[]
  columns: DataTableColumn<Mention>[]
  canExport: boolean
}) {
  return (
    <GatedAction allowed={canExport} reason="خروجی گرفتن فقط برای اعضای فضای کار ممکن است">
      <DataTableExportButton columns={columns} data={rows} filename="mentions.csv" label="خروجی CSV" />
    </GatedAction>
  )
}

اقدام‌های منوی هر ردیف

اقدامی در منوی عملیات یک ردیف (DropdownMenuItem) که کاربر اجازه‌اش را ندارد از منو کنار گذاشته می‌شود ({canDelete && <DropdownMenuItem …>حذف</DropdownMenuItem>})، نه غیرفعال و نه در GatedAction. دلیلش این است که گزینهٔ غیرفعال منو با کلیدهای پیکان رد می‌شود و hover و لمس هم نمی‌گیرد، پس هیچ‌وقت نمی‌تواند بگوید چرا؛ و GatedAction فقط یک Button را می‌تواند غیرفعال کند. منوی ردیف فهرست کارهایی است که کاربر روی همان ردیف می‌تواند انجام دهد. اقدام صفحه فرق دارد: دکمه‌ای است که همیشه دیده می‌شود، پس در جایگاهش غیرفعال با دلیلش می‌ماند. اگر کاربر هیچ‌یک از اقدام‌های ردیف را نمی‌تواند انجام دهد، ستون عملیات هم نیست. قاعدهٔ parto/page-primary-action DropdownMenuItem disabled={!canDelete} و GatedAction دور گزینهٔ منو را نشان می‌دهد.

راهنمای استفاده

بکنید

  • هر اقدامی را که کاربر اکنون نمی‌تواند انجام دهد در جایگاه خودش با GatedAction بگذارید، اقدام اصلی یا ثانوی یا گروهی.
  • در reason بگویید چه کسی می‌تواند این کار را بکند، یا وقتی وضعیت صفحه تصمیم می‌گیرد، چه چیزی آن را ممکن می‌کند.
  • اقدامی را که با ConfirmDialog تأیید می‌شود با GatedAction در trigger پنجره بنویسید.

نکنید

  • اقدام را پنهان نکنید: نه canCreate && <Button/>، نه canCreate ? <Button/> : null (قاعدهٔ ESLint parto/page-primary-action هر دو را نشان می‌دهد).
  • Button disabled بی دلیل در جایگاه اقدام صفحه نگذارید، و Tooltip خودتان را دورش نسازید؛ GatedAction هر دو را دارد (همان قاعده این را هم نشان می‌دهد).
  • برای صفحه‌ای که کاربر اجازهٔ دیدنش را ندارد اقدام‌ها را یکی‌یکی نبندید: UtilityPage kind="403".
  • ConfirmDialog یا کامپوننت خودتان را درون GatedAction نگذارید: فرزندش فقط یک Button (یا DataTableExportButton) است (قاعدهٔ parto/page-primary-action این را هم نشان می‌دهد).
  • خروجی جدول را برای فهرست بی‌ردیف نبندید و پنهان نکنید (GatedAction allowed={rows.length > 0}، disabled، rows.length > 0 && …): قالب صفحه این کار را با دلیلش می‌کند.
  • گزینهٔ منوی ردیف را با disabled={!canDelete} یا GatedAction نبندید: آن را از منو کنار بگذارید.

Props

GatedAction

Prop

Type

دسترسی‌پذیری

  • اقدام غیرفعال aria-disabled="true" است، نه disabled: در ترتیب Tab می‌ماند و صفحه‌خوان آن را «غیرفعال» با دلیلش (aria-describedby) می‌خواند.
  • دلیل با فوکوس، hover، کلیک یا لمس در راهنمای ابزار باز می‌شود و Escape آن را می‌بندد.
  • Enter، Space یا کلیک روی اقدام غیرفعال هیچ کاری جز نشان دادن دلیل نمی‌کند؛ خروجی جدول منویش را باز نمی‌کند.
  • وقتی allowed در حین کار با صفحه عوض می‌شود (بار شدن فهرست، رسیدن نخستین نتیجه)، همان دکمه می‌ماند و فوکوس کاربری که با Tab به آن رسیده از آن نمی‌رود (به‌جز اقدام پیوندی، که وقتی اجازه نیست دکمه است نه پیوند).

کامپوننت‌های مرتبط

  • انتخاب قالب صفحه — جایگاه‌های اقدام هر قالب.
  • UtilityPage — نوع 403 برای صفحه‌ای که کاربر اجازهٔ دیدنش را ندارد.
  • Tooltip — راهنمای ابزاری که GatedAction برای دلیل به کار می‌برد.
  • ConfirmDialog — پنجرهٔ تأییدی که GatedAction ماشه‌اش می‌شود.
  • ListPage — اقدام‌های گروهی انتخاب ردیف‌ها.