feat(FN-4996): complete Step 1 — add tertiary confirm choice
Fusion-Task-Id: FN-4996 Fusion-Task-Lineage: 650bb5e4-18f2-41a0-ae46-b9112c4a0d74
This commit is contained in:
committed by
gsxdsm
parent
dd0d4b74b7
commit
44562cdf78
@@ -13,6 +13,7 @@
|
||||
.confirm-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@@ -35,4 +36,9 @@
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.confirm-dialog__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
options: ConfirmOptions | null;
|
||||
onConfirm: () => void;
|
||||
onTertiary?: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ isOpen, options, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
export function ConfirmDialog({ isOpen, options, onConfirm, onTertiary, onCancel }: ConfirmDialogProps) {
|
||||
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,6 +57,11 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onCancel }: ConfirmD
|
||||
<button ref={cancelButtonRef} className="btn" onClick={onCancel}>
|
||||
{options.cancelLabel ?? "Cancel"}
|
||||
</button>
|
||||
{options.tertiaryLabel && onTertiary ? (
|
||||
<button className={`btn ${options.tertiaryDanger ? "btn-danger" : ""}`.trim()} onClick={onTertiary}>
|
||||
{options.tertiaryLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button className={`btn ${options.danger ? "btn-danger" : "btn-primary"}`} onClick={onConfirm}>
|
||||
{options.confirmLabel ?? "Confirm"}
|
||||
</button>
|
||||
|
||||
@@ -79,6 +79,22 @@ describe("ConfirmDialog", () => {
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders and handles tertiary action when configured", () => {
|
||||
const onTertiary = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
options={{ title: "Delete Done", message: "Delete or archive?", tertiaryLabel: "Archive Instead" }}
|
||||
onConfirm={vi.fn()}
|
||||
onTertiary={onTertiary}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Archive Instead" }));
|
||||
expect(onTertiary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("focuses cancel button on mount", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState } from "react";
|
||||
import { ConfirmDialogProvider, useConfirm } from "../useConfirm";
|
||||
|
||||
function Harness() {
|
||||
const { confirm } = useConfirm();
|
||||
const { confirm, confirmWithChoice } = useConfirm();
|
||||
const [result, setResult] = useState<string>("idle");
|
||||
|
||||
return React.createElement(
|
||||
@@ -30,6 +30,21 @@ function Harness() {
|
||||
},
|
||||
"queue"
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: async () => {
|
||||
const choice = await confirmWithChoice({
|
||||
title: "Delete Done",
|
||||
message: "Delete or archive?",
|
||||
confirmLabel: "Delete",
|
||||
tertiaryLabel: "Archive Instead",
|
||||
});
|
||||
setResult(choice);
|
||||
},
|
||||
},
|
||||
"open-choice"
|
||||
),
|
||||
React.createElement("div", { "data-testid": "result" }, result)
|
||||
);
|
||||
}
|
||||
@@ -69,6 +84,57 @@ describe("useConfirm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves tertiary choice when tertiary button clicked", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
ConfirmDialogProvider,
|
||||
null,
|
||||
React.createElement(Harness)
|
||||
)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("open-choice"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Archive Instead" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("result").textContent).toBe("tertiary");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves cancel choice when cancel is clicked", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
ConfirmDialogProvider,
|
||||
null,
|
||||
React.createElement(Harness)
|
||||
)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("open-choice"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("result").textContent).toBe("cancel");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves primary choice when confirm is clicked", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
ConfirmDialogProvider,
|
||||
null,
|
||||
React.createElement(Harness)
|
||||
)
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("open-choice"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("result").textContent).toBe("primary");
|
||||
});
|
||||
});
|
||||
|
||||
it("queues confirmations sequentially", async () => {
|
||||
render(
|
||||
React.createElement(
|
||||
|
||||
@@ -9,15 +9,20 @@ export interface ConfirmOptions {
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
tertiaryLabel?: string;
|
||||
tertiaryDanger?: boolean;
|
||||
}
|
||||
|
||||
export type ConfirmChoice = "primary" | "tertiary" | "cancel";
|
||||
|
||||
interface PendingConfirm {
|
||||
options: ConfirmOptions;
|
||||
resolve: (value: boolean) => void;
|
||||
resolve: (value: ConfirmChoice) => void;
|
||||
}
|
||||
|
||||
interface ConfirmContextValue {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
confirmWithChoice: (options: ConfirmOptions) => Promise<ConfirmChoice>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
@@ -34,13 +39,18 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const confirmWithChoice = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<ConfirmChoice>((resolve) => {
|
||||
updateQueue((current) => [...current, { options, resolve }]);
|
||||
});
|
||||
}, [updateQueue]);
|
||||
|
||||
const resolveCurrent = useCallback((value: boolean) => {
|
||||
const confirm = useCallback(async (options: ConfirmOptions) => {
|
||||
const choice = await confirmWithChoice(options);
|
||||
return choice === "primary";
|
||||
}, [confirmWithChoice]);
|
||||
|
||||
const resolveCurrent = useCallback((value: ConfirmChoice) => {
|
||||
const current = queueRef.current[0];
|
||||
if (!current) {
|
||||
return;
|
||||
@@ -52,7 +62,7 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const active = queue[0] ?? null;
|
||||
|
||||
const contextValue = useMemo<ConfirmContextValue>(() => ({ confirm }), [confirm]);
|
||||
const contextValue = useMemo<ConfirmContextValue>(() => ({ confirm, confirmWithChoice }), [confirm, confirmWithChoice]);
|
||||
|
||||
return React.createElement(
|
||||
ConfirmContext.Provider,
|
||||
@@ -61,8 +71,9 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
|
||||
React.createElement(ConfirmDialog, {
|
||||
isOpen: active !== null,
|
||||
options: active?.options ?? null,
|
||||
onConfirm: () => resolveCurrent(true),
|
||||
onCancel: () => resolveCurrent(false),
|
||||
onConfirm: () => resolveCurrent("primary"),
|
||||
onTertiary: () => resolveCurrent("tertiary"),
|
||||
onCancel: () => resolveCurrent("cancel"),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -75,5 +86,6 @@ export function useConfirm(): ConfirmContextValue {
|
||||
|
||||
return {
|
||||
confirm: async (_options: ConfirmOptions) => false,
|
||||
confirmWithChoice: async (_options: ConfirmOptions) => "cancel",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user