پرتوپرتو

دیالوگ تأیید حذف

الگوی دیالوگ تأیید برای عملیات مخرب مثل حذف

معرفی

عملیات مخرب (حذف کمپین، حذف گزارش، بستن حساب) برگشت‌ناپذیرند؛ یک کلیک اشتباه نباید داده‌ای را از بین ببرد. الگوی «دیالوگ تأیید حذف» بین کلیک کاربر و اجرای عمل یک ایست عمدی قرار می‌دهد: پیامد عمل را صریح توضیح می‌دهد، یک تصمیم دوگزینه‌ای واضح می‌گیرد، و در حین اجرای عملیات async حالت «در حال پردازش» را نمایش می‌دهد.

این الگو نشان می‌دهد چگونه یک دیالوگ تأیید برای عملیات حذف بسازید که:

  • از ConfirmDialog (و برای کنترل کامل، از AlertDialog) برای عملیات مخرب استفاده می‌کند — نه Dialog
  • وضعیت بارگذاری دکمه تأیید را مدیریت می‌کند
  • پس از تأیید، عملیات async را اجرا می‌کند؛ با موفقیت بسته می‌شود و با خطا باز می‌ماند

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

  • حذف یا تغییر برگشت‌ناپذیر یک منبع (کمپین، گزارش، حساب کاربری)
  • عملی با هزینه یا عارضه جانبی قابل توجه که کاربر باید پیش از اجرا آگاهانه بپذیرد

چه زمانی استفاده نکنیم (جایگزین‌ها):

  • عمل به‌راحتی برگشت‌پذیر است — بدون قطع جریان کاربر اجرا کنید و با toast امکان «بازگردانی» بدهید
  • تأیید به فرم یا چند فیلد ورودی نیاز دارد — از Dialog استفاده کنید (راهنمای انتخاب: مودالیتی)
  • فقط اطلاع‌رسانی است و تصمیمی در کار نیست — از toast یا Callout استفاده کنید

نمونه بصری

پیاده‌سازی

مسیر استاندارد این الگو کامپوننت آماده ConfirmDialog است. برای عملیات async حتماً حالت کنترل‌شده (open / onOpenChange) را به‌کار ببرید — بستن خودکار پس از موفقیت از طریق onOpenChange(false) انجام می‌شود و بدون آن، دیالوگ پس از پایان موفق عملیات باز می‌ماند.

'use client'

import * as React from 'react'
import { ConfirmDialog, Button } from '@partodata/ui'

interface DeleteCampaignConfirmProps {
  campaignName: string
  onDelete: () => Promise<void>
}

export function DeleteCampaignConfirm({ campaignName, onDelete }: DeleteCampaignConfirmProps) {
  const [open, setOpen] = React.useState(false)

  return (
    <ConfirmDialog
      open={open}
      onOpenChange={setOpen}
      trigger={<Button variant="destructive">حذف کمپین</Button>}
      variant="destructive"
      title={`حذف کمپین «${campaignName}»؟`}
      description="همهٔ داده‌های این کمپین برای همیشه حذف می‌شود و امکان بازیابی وجود ندارد."
      confirmLabel="حذف دائمی"
      onConfirm={onDelete}
    />
  )
}

رفتارهایی که ConfirmDialog به‌صورت داخلی مدیریت می‌کند (نیازی به کدنویسی مجدد نیست):

  • اگر onConfirm یک promise برگرداند، دکمه تأیید تا حل شدن آن خودبه‌خود حالت در حال پردازش (spinner + غیرفعال) می‌گیرد و دکمه انصراف هم غیرفعال می‌شود
  • در حین اجرا، دیالوگ حتی با Escape یا تلاش برای بستن هم بسته نمی‌شود تا عملیات نیمه‌کاره رها نشود
  • با موفقیت، دیالوگ بسته می‌شود؛ با شکست (reject)، باز می‌ماند تا پیام خطای شما در همان زمینه دیده شود

الگوهای رایج

تأیید تایپی برای منابع نام‌دار

برای مخرب‌ترین عملیات (حذف دائمی یک منبع نام‌دار)، confirmString یک «دست‌انداز عمدی» اضافه می‌کند: دکمه تأیید تا وقتی کاربر عبارت دقیق را تایپ نکند فعال نمی‌شود.

<ConfirmDialog
  trigger={<Button variant="destructive">حذف کمپین</Button>}
  variant="destructive"
  title="حذف کمپین «تخفیف فصلی»؟"
  description="همهٔ داده‌های این کمپین برای همیشه حذف می‌شود."
  confirmString="تخفیف فصلی"
  confirmLabel="حذف دائمی"
  onConfirm={deleteCampaign}
/>

بازخورد نتیجه با toast

نتیجه عملیات را با toast اعلام کنید. برای شکست، خطا را دوباره پرتاب کنید تا ConfirmDialog باز بماند و کاربر بتواند دوباره تلاش یا انصراف دهد.

import { toast } from '@partodata/ui'

async function deleteCampaign() {
  try {
    await api.deleteCampaign(campaignId)
    toast.success('کمپین «تخفیف فصلی» حذف شد')
  } catch (error) {
    toast.error('حذف کمپین انجام نشد. دوباره تلاش کنید.')
    throw error // keep the dialog open so the user can retry or cancel
  }
}

ساخت دستی با AlertDialog — کنترل کامل

وقتی به آناتومی سفارشی نیاز دارید (محتوای اضافه بین سربرگ و فوتر، چند مرحله، چیدمان خاص)، الگو را مستقیم با AlertDialog بسازید. دو نکته حیاتی: Radix با کلیک روی AlertDialogAction دیالوگ را می‌بندد، پس برای دیده‌شدن حالت بارگذاری باید event.preventDefault() را فراخوانی کنید و بستن را خودتان پس از موفقیت انجام دهید؛ و استایل مخرب را با کلاس توکن‌محور مستقیماً روی خود AlertDialogAction بگذارید (همان کاری که سورس ConfirmDialog می‌کند)، نه با تودرتو کردن Button.

'use client'

import * as React from 'react'
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  Button,
} from '@partodata/ui'

interface DeleteConfirmProps {
  itemName: string
  onDelete: () => Promise<void>
}

export function DeleteConfirmDialog({ itemName, onDelete }: DeleteConfirmProps) {
  const [open, setOpen] = React.useState(false)
  const [isDeleting, setIsDeleting] = React.useState(false)

  async function handleDelete(event: React.MouseEvent<HTMLButtonElement>) {
    // Radix closes the dialog on action-click; suppress that so the
    // pending state stays visible, then close manually on success.
    event.preventDefault()
    setIsDeleting(true)
    try {
      await onDelete()
      setOpen(false)
    } finally {
      setIsDeleting(false)
    }
  }

  return (
    <AlertDialog
      open={open}
      onOpenChange={(next) => {
        if (!isDeleting) setOpen(next) // never close mid-operation
      }}
    >
      <AlertDialogTrigger asChild>
        <Button variant="destructive">حذف</Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>آیا از حذف مطمئن هستید؟</AlertDialogTitle>
          <AlertDialogDescription>
            این عملیات غیرقابل بازگشت است. «{itemName}» به‌طور کامل حذف خواهد شد و امکان بازیابی آن وجود ندارد.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel disabled={isDeleting}>انصراف</AlertDialogCancel>
          <AlertDialogAction
            onClick={handleDelete}
            disabled={isDeleting}
            className="bg-destructive hover:bg-destructive-600 text-on-destructive"
          >
            {isDeleting ? 'در حال حذف…' : 'حذف'}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

بهترین روش‌ها و دام‌های رایج

بکنید

  • همیشه از AlertDialog یا ConfirmDialog به‌جای Dialog برای تأیید عملیات مخرب استفاده کنید — با کلیک بیرون بسته نمی‌شود و فوکوس اولیه روی دکمه انصراف (کم‌خطرترین گزینه) قرار می‌گیرد - دکمه لغو را در حین اجرا disabled کنید تا از تداخل جلوگیری شود - متن توضیحی باید پیامدهای عمل را به‌وضوح بیان کند («برای همیشه حذف می‌شود»)

نکنید

  • متن دکمه تأیید را عمومی («بله»، «تأیید») نگذارید — نام خود عمل را بنویسید («حذف دائمی») - برای اعمال برگشت‌پذیر، جریان کاربر را با دیالوگ قطع نکنید — اجرا به‌همراه toast بازگردانی کافی است - چند تأیید پشت‌سرهم برای یک عمل نگذارید — به‌جای آن برای مخرب‌ترین موارد از confirmString استفاده کنید

دام‌های رایج

۱. بسته شدن دیالوگ پیش از پایان عملیات async

اشتباه: قرار دادن onClick async روی AlertDialogAction (یا دکمه داخل آن) بدون event.preventDefault(). چرا مشکل‌ساز است: Radix با هر کلیک روی Action دیالوگ را می‌بندد؛ در نتیجه spinner هرگز دیده نمی‌شود، دیالوگ پیش از پایان حذف ناپدید می‌شود و کاربر ممکن است عمل را تکرار کند. الگوی درست: با ConfirmDialog فقط یک promise از onConfirm برگردانید (کامپوننت خودش جلوی بستن را می‌گیرد)؛ در نسخه دستی، در ابتدای handler تابع event.preventDefault() را فراخوانی کنید و پس از موفقیت خودتان setOpen(false) کنید.

۲. حالت غیرکنترل‌شده برای عملیات async

اشتباه: استفاده از ConfirmDialog با onConfirm async ولی بدون open / onOpenChange. چرا مشکل‌ساز است: بستن خودکار پس از موفقیت از طریق فراخوانی onOpenChange(false) انجام می‌شود؛ وقتی این prop را نداده باشید، فراخوانی بی‌اثر است و دیالوگ پس از حذف موفق همچنان باز می‌ماند. الگوی درست: برای هر onConfirm که promise برمی‌گرداند، دیالوگ را کنترل‌شده رندر کنید (مانند نمونه بخش «پیاده‌سازی»).

۳. رنگ هاردکد برای دکمه مخرب

اشتباه: استایل‌دادن دکمه تأیید با رنگ ثابت:

// ❌ فقط در یک تم درست دیده می‌شود و lint را رد نمی‌کند
<AlertDialogAction className="bg-red-600 text-white">حذف</AlertDialogAction>

// ✅ توکن‌های سیستم — در تم تیره (پیش‌فرض) و روشن درست است
<AlertDialogAction className="bg-destructive hover:bg-destructive-600 text-on-destructive">حذف</AlertDialogAction>

چرا مشکل‌ساز است: این دیزاین‌سیستم dark-first است و همه رنگ‌ها باید از توکن‌ها بیایند؛ رنگ هاردکد در تم مقابل شکسته دیده می‌شود و قانون ESLint no-hardcoded-colors بیلد را رد می‌کند. الگوی درست: variant="destructive" در ConfirmDialog، یا همان کلاس‌های توکن‌محوری که سورس خود کامپوننت استفاده می‌کند.

۴. ویژگی‌های فیزیکی CSS در چیدمان دیالوگ

اشتباه: فاصله‌گذاری آیکون یا تراز فوتر با ویژگی‌های فیزیکی مثل mr-2، pl-6 یا text-left. چرا مشکل‌ساز است: این سیستم RTL-first است و از CSS Logical Properties استفاده می‌کند؛ ویژگی فیزیکی در RTL آینه نمی‌شود و چیدمان به‌هم می‌ریزد — قانون ESLint no-physical-css-properties هم آن را رد می‌کند. الگوی درست: معادل منطقی — me-2، ps-6، text-start (سورس AlertDialogFooter هم دقیقاً از ps-6 / pe-6 استفاده می‌کند).

۵. حذف خوش‌بینانه ردیف در جدول صفحه‌بندی‌شده سروری

اشتباه: پس از تأیید حذفِ یک ردیف در DataTable با صفحه‌بندی سمت سرور، ردیف را فقط از آرایه محلی state فیلتر کنید. چرا مشکل‌ساز است: شمارنده کل نتایج و تعداد صفحات با سرور ناهماهنگ می‌شود — صفحه فعلی یک ردیف کم می‌آورد، شماره صفحات کهنه می‌ماند و با رفتن به صفحه بعد داده‌ها جابه‌جا دیده می‌شوند. الگوی درست: پس از resolve شدن onConfirm، صفحه فعلی (و شمارنده کل) را از سرور دوباره واکشی یا invalidate کنید و اجازه دهید جدول از پاسخ تازه رندر شود.

صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • ConfirmDialog — وقتی همین الگو را می‌خواهید و فقط مرجع کامل props (مثل alert، locale، loading) لازم دارید.
  • مودالیتی — اگر مطمئن نیستید تعامل شما دیالوگ تأیید می‌خواهد یا Dialog، Sheet، Popover یا DropdownMenu، اول این راهنمای انتخاب را ببینید.
  • صفحه جدول داده — رایج‌ترین محل ظهور این الگو: اکشن حذف روی ردیف‌های جدول با صفحه‌بندی سروری.
  • الگوهای خطا — وقتی عملیات حذف شکست می‌خورد، نمایش خطا را با این الگوها هماهنگ کنید.