shadcn-ui / ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
https://ui.shadcn.com
MIT License
74.84k stars 4.66k forks source link

[bug]: The Dialog closes automatically after the async call if it is in the DataTable. #4261

Open jihea-park opened 4 months ago

jihea-park commented 4 months ago

Describe the bug

The Dialog closes after asynchronous function in the DataTable. I think after onDelete, the dialog still opens. But it closes.

Affected component/components

Dialog, DataTabe

How to reproduce

// app/posts/page.tsx
'use client';

import {
  useMutation as useTanstackMutation,
  useQuery as useTanstackQuery,
} from '@tanstack/react-query';
import axios from 'axios';
import DataTable from '@/components/DataTable';
import ConfirmWithInputDialog from '@/components/ConfirmWithInputDialog';
import { Button } from '@/components/ui/button';

export default function PostsPage() {
  const { data, refetch } = useTanstackQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await axios.get('https://jsonplaceholder.typicode.com/posts');
      return res?.data;
    },
  });

  const mutation = useTanstackMutation({
    mutationFn: (id: string) => {
      return axios.delete(`https://jsonplaceholder.typicode.com/posts/${id}`);
    },
  });

  async function onDelete(id: string) {
    console.log('id', id);
    await mutation.mutateAsync(id);
    refetch();
  }

  const columns = [
    {
      accessorKey: 'id',
      header: 'ID',
    },
    {
      accessorKey: 'userId',
      header: 'User Id',
    },
    {
      accessorKey: 'title',
      header: 'Title',
    },
    {
      accessorKey: 'body',
      header: 'Body',
    },
    {
      header: 'Action',
      enableSorting: false,
      cell: ({ row }: { row: any }) => {
        const id = String(row?.original?.id);
        return (
          <ConfirmWithInputDialog
            title={'Delete'}
            content={
              <>
                <div className="text-primary">{id} is to be deleted.</div>
                <div className="text-destructive">Really delete?</div>
              </>
            }
            input={id}
            okText={'Delete'}
            okVariant="destructive"
            onOk={() => onDelete?.(id)}
          >
            <Button size="sm" variant="destructive">
              Delete
            </Button>
          </ConfirmWithInputDialog>
        );
      },
    },
  ];

  return (
    <>
      <DataTable data={data || []} columns={columns} />
    </>
  );
}
// @/components/ConfirmWithInputDialog
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  DialogFooter,
  DialogClose,
} from '@/components/ui/dialog';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import React from 'react';
import { Input } from '@/components/ui/input';

export default function ConfirmWithInputDialog({
  children,
  onOk,
  content,
  input,
  title,
  description,
  okVariant,
  okText,
  cancelText,
}: {
  children: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  content?: React.ReactNode;
  input: string;
  okVariant?:
    | 'link'
    | 'default'
    | 'destructive'
    | 'outline'
    | 'secondary'
    | 'ghost';
  okText?: React.ReactNode;
  cancelText?: React.ReactNode;
  onOk?: () => Promise<boolean | void> | boolean | void;
}) {
  const [open, setOpen] = React.useState(false);
  const [text, setText] = React.useState('');
  const [error, setError] = React.useState('');

  React.useEffect(() => {
    setText('');
    setError('');
  }, [open]);

  async function handleOk() {
    if (!text || text !== input) {
      setError(`Error: "${text}" does not match "${input}".`);
      return;
    }

    const res = await onOk?.();
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <div>{children}</div>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          {title && <DialogTitle>{title}</DialogTitle>}
          {description && <DialogDescription>{description}</DialogDescription>}
        </DialogHeader>
        <>
          {content}
          <div>
            <Input
              placeholder={`Please write "${input}" to confirm.`}
              value={text}
              onChange={(e) => setText(e.target.value)}
            />

            {error && (
              <Alert variant="destructive" className="mt-3 p-2">
                <div className="flex items-center gap-2">
                  <AlertCircle className="h-4 w-4" />
                  <AlertDescription>{error}</AlertDescription>
                </div>
              </Alert>
            )}
          </div>
        </>
        <DialogFooter>
          <DialogClose asChild>
            <Button size="sm" variant="secondary">
              {cancelText || 'Cancel'}
            </Button>
          </DialogClose>
          <Button size="sm" onClick={handleOk} variant={okVariant || 'default'}>
            {okText || 'Ok'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
// import DataTable from '@/components/DataTable';
'use client';

import {
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import React from 'react';

export default function DataTable({ columns, data = [] }: any) {
  const table = useReactTable({
    data: data || [],
    columns,
    getCoreRowModel: getCoreRowModel(),
  });
  return (
    <Table>
      <TableHeader>
        {table.getHeaderGroups().map((headerGroup) => (
          <TableRow key={headerGroup.id}>
            {headerGroup.headers.map((header) => {
              return (
                <TableHead key={header.id}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                </TableHead>
              );
            })}
          </TableRow>
        ))}
      </TableHeader>
      <TableBody>
        {table.getRowModel().rows?.length ? (
          table.getRowModel().rows.map((row) => (
            <TableRow
              key={row.id}
              data-state={row.getIsSelected() && 'selected'}
            >
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))
        ) : (
          <TableRow>
            <TableCell colSpan={columns.length} className="h-24 text-center">
              No results.
            </TableCell>
          </TableRow>
        )}
      </TableBody>
    </Table>
  );
}

Codesandbox/StackBlitz link

No response

Logs

No response

System Info

Mac 14.5(23F79)
Chrome 125.0.6422.113

Before submitting

Hasnainahmad04 commented 3 months ago

Getting same issue when using alert dialog in Data Table actions. @jiheapark do you find any solution ?

shiftcontrol-dan commented 3 months ago

I have the same issue. It happens with pretty much any shadcn component that has an open state. I've tried tracking the state and passing it to the component that houses the table, it works, but the component briefly flashes closed and back open, which isn't a good experience.

For me, this doesn't happen on an async request from the table, it happens on a background auth token request. So it seems any re-render is causing this issue.

shiftcontrol-dan commented 3 months ago

After some troubleshooting the solution is to memoize the column. https://stackoverflow.com/questions/62666841/using-react-table-with-react-modal If you memorize the column definition like this in your example, then any shadcn component that uses open states will work fine:

const columns = useMemo(() => [
    {
      accessorKey: 'id',
      header: 'ID',
    },
    {
      accessorKey: 'userId',
      header: 'User Id',
    },
    {
      accessorKey: 'title',
      header: 'Title',
    },
    {
      accessorKey: 'body',
      header: 'Body',
    },
    {
      header: 'Action',
      enableSorting: false,
      cell: ({ row }: { row: any }) => {
        const id = String(row?.original?.id);
        return (
          <ConfirmWithInputDialog
            title={'Delete'}
            content={
              <>
                <div className="text-primary">{id} is to be deleted.</div>
                <div className="text-destructive">Really delete?</div>
              </>
            }
            input={id}
            okText={'Delete'}
            okVariant="destructive"
            onOk={() => onDelete?.(id)}
          >
            <Button size="sm" variant="destructive">
              Delete
            </Button>
          </ConfirmWithInputDialog>
        );
      },
    },
  ], []);
jihea-park commented 5 days ago

@shiftcontrol-dan It works good. Thank you!!!