mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
fix: prevent duplicate dialog delete submissions
This commit is contained in:
+69
-58
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { RiLoaderLine } from "@remixicon/react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { runDialogAction } from "@/lib/feedback/dialog-action"
|
||||
import { useDialogController } from "@/lib/feedback/dialog"
|
||||
import type { DialogInstance } from "@/lib/feedback/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -19,77 +22,85 @@ function positiveButtonVariant(dialog: DialogInstance) {
|
||||
return dialog.tone === "destructive" ? "destructive" : dialog.tone === "warning" ? "secondary" : "default"
|
||||
}
|
||||
|
||||
function handleAction(
|
||||
controller: ReturnType<typeof useDialogController>,
|
||||
dialog: DialogInstance,
|
||||
action?: DialogInstance["onPositiveClick"] | DialogInstance["onNegativeClick"],
|
||||
) {
|
||||
if (!action) {
|
||||
controller.close(dialog.id)
|
||||
return
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const result = await action()
|
||||
if (result === false) return
|
||||
controller.close(dialog.id)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
}
|
||||
|
||||
export function DialogHost() {
|
||||
const controller = useDialogController()
|
||||
const dialogs = controller.dialogs
|
||||
const pendingDialogIdsRef = React.useRef(new Set<string>())
|
||||
const [pendingDialogIds, setPendingDialogIds] = React.useState<Set<string>>(new Set())
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs.map((dialog) => (
|
||||
<AlertDialog key={dialog.id} open={dialog.open} onOpenChange={(value) => controller.setOpen(dialog.id, value)}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
{dialog.title && <AlertDialogTitle>{dialog.title}</AlertDialogTitle>}
|
||||
{dialog.content && <AlertDialogDescription>{dialog.content}</AlertDialogDescription>}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
{dialog.negativeText && (
|
||||
<AlertDialogCancel asChild>
|
||||
{dialogs.map((dialog) => {
|
||||
const isPending = pendingDialogIds.has(dialog.id)
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
key={dialog.id}
|
||||
open={dialog.open}
|
||||
onOpenChange={(value) => controller.setOpen(dialog.id, value)}
|
||||
>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
{dialog.title && <AlertDialogTitle>{dialog.title}</AlertDialogTitle>}
|
||||
{dialog.content && <AlertDialogDescription>{dialog.content}</AlertDialogDescription>}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
{dialog.negativeText && (
|
||||
<AlertDialogCancel asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto text-foreground")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
void runDialogAction({
|
||||
dialogId: dialog.id,
|
||||
pendingIds: pendingDialogIdsRef.current,
|
||||
setPendingIds: setPendingDialogIds,
|
||||
action: dialog.onNegativeClick,
|
||||
close: () => controller.close(dialog.id),
|
||||
onError: (error) => {
|
||||
console.error(error)
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{dialog.negativeText}
|
||||
</button>
|
||||
</AlertDialogCancel>
|
||||
)}
|
||||
<AlertDialogAction asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: "outline" }), "w-full sm:w-auto text-foreground")}
|
||||
disabled={isPending}
|
||||
className={cn(
|
||||
buttonVariants({ variant: positiveButtonVariant(dialog) }),
|
||||
"w-full sm:w-auto",
|
||||
positiveButtonVariant(dialog) === "destructive" && "text-white",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleAction(controller, dialog, dialog.onNegativeClick)
|
||||
void runDialogAction({
|
||||
dialogId: dialog.id,
|
||||
pendingIds: pendingDialogIdsRef.current,
|
||||
setPendingIds: setPendingDialogIds,
|
||||
action: dialog.onPositiveClick,
|
||||
close: () => controller.close(dialog.id),
|
||||
onError: (error) => {
|
||||
console.error(error)
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{dialog.negativeText}
|
||||
{isPending ? <RiLoaderLine className="size-4 animate-spin" aria-hidden="true" /> : null}
|
||||
<span>{dialog.positiveText || "Confirm"}</span>
|
||||
</button>
|
||||
</AlertDialogCancel>
|
||||
)}
|
||||
<AlertDialogAction asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
buttonVariants({ variant: positiveButtonVariant(dialog) }),
|
||||
"w-full sm:w-auto",
|
||||
positiveButtonVariant(dialog) === "destructive" && "text-white",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleAction(controller, dialog, dialog.onPositiveClick)
|
||||
}}
|
||||
>
|
||||
{dialog.positiveText || "Confirm"}
|
||||
</button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
))}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export async function runDialogAction({ dialogId, pendingIds, setPendingIds, action, close, onError }) {
|
||||
if (!action) {
|
||||
close()
|
||||
return true
|
||||
}
|
||||
|
||||
if (pendingIds.has(dialogId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingIds.add(dialogId)
|
||||
setPendingIds(new Set(pendingIds))
|
||||
|
||||
try {
|
||||
const result = await action()
|
||||
if (result !== false) {
|
||||
close()
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
onError?.(error)
|
||||
return false
|
||||
} finally {
|
||||
pendingIds.delete(dialogId)
|
||||
setPendingIds(new Set(pendingIds))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
type DialogActionResult = void | boolean | Promise<void | boolean>
|
||||
|
||||
interface RunDialogActionOptions {
|
||||
dialogId: string
|
||||
pendingIds: Set<string>
|
||||
setPendingIds: (pendingIds: Set<string>) => void
|
||||
action?: () => DialogActionResult
|
||||
close: () => void
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
export async function runDialogAction({
|
||||
dialogId,
|
||||
pendingIds,
|
||||
setPendingIds,
|
||||
action,
|
||||
close,
|
||||
onError,
|
||||
}: RunDialogActionOptions): Promise<boolean> {
|
||||
if (!action) {
|
||||
close()
|
||||
return true
|
||||
}
|
||||
|
||||
if (pendingIds.has(dialogId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingIds.add(dialogId)
|
||||
setPendingIds(new Set(pendingIds))
|
||||
|
||||
try {
|
||||
const result = await action()
|
||||
if (result !== false) {
|
||||
close()
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
onError?.(error)
|
||||
return false
|
||||
} finally {
|
||||
pendingIds.delete(dialogId)
|
||||
setPendingIds(new Set(pendingIds))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { runDialogAction } from "../../lib/feedback/dialog-action.js"
|
||||
|
||||
test("runDialogAction ignores duplicate submissions while the current action is pending", async () => {
|
||||
const pendingIds = new Set<string>()
|
||||
const pendingSnapshots: string[][] = []
|
||||
let actionCalls = 0
|
||||
let closeCalls = 0
|
||||
let resolveAction: (() => void) | undefined
|
||||
|
||||
const firstRun = runDialogAction({
|
||||
dialogId: "dialog-1",
|
||||
pendingIds,
|
||||
setPendingIds: (nextPendingIds) => {
|
||||
pendingSnapshots.push(Array.from(nextPendingIds))
|
||||
},
|
||||
action: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
actionCalls += 1
|
||||
resolveAction = resolve
|
||||
}),
|
||||
close: () => {
|
||||
closeCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
const duplicateRunStarted = await runDialogAction({
|
||||
dialogId: "dialog-1",
|
||||
pendingIds,
|
||||
setPendingIds: () => {},
|
||||
action: () => {
|
||||
actionCalls += 1
|
||||
},
|
||||
close: () => {
|
||||
closeCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(duplicateRunStarted, false)
|
||||
assert.equal(actionCalls, 1)
|
||||
assert.equal(closeCalls, 0)
|
||||
|
||||
resolveAction?.()
|
||||
const firstRunStarted = await firstRun
|
||||
|
||||
assert.equal(firstRunStarted, true)
|
||||
assert.equal(closeCalls, 1)
|
||||
assert.deepEqual(pendingSnapshots, [["dialog-1"], []])
|
||||
assert.equal(pendingIds.size, 0)
|
||||
})
|
||||
|
||||
test("runDialogAction keeps the dialog open when the action explicitly returns false", async () => {
|
||||
const pendingIds = new Set<string>()
|
||||
const pendingSnapshots: string[][] = []
|
||||
let closeCalls = 0
|
||||
|
||||
const started = await runDialogAction({
|
||||
dialogId: "dialog-2",
|
||||
pendingIds,
|
||||
setPendingIds: (nextPendingIds) => {
|
||||
pendingSnapshots.push(Array.from(nextPendingIds))
|
||||
},
|
||||
action: async () => false,
|
||||
close: () => {
|
||||
closeCalls += 1
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(started, true)
|
||||
assert.equal(closeCalls, 0)
|
||||
assert.deepEqual(pendingSnapshots, [["dialog-2"], []])
|
||||
assert.equal(pendingIds.size, 0)
|
||||
})
|
||||
Reference in New Issue
Block a user