milestone 23 s2: react aria primitives and their cluster
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The destructive-action confirmation (milestone 23, ruling 5), which replaces
|
||||
* every `window.confirm` call in the app.
|
||||
*
|
||||
* `role="alertdialog"` rather than `dialog`: the message is the reason the
|
||||
* dialog exists, so it is announced with the dialog instead of after it. The
|
||||
* overlay is deliberately not dismissable — a delete needs an explicit answer,
|
||||
* and a stray outside click is not one. Escape still cancels.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
/** The full sentence the operator reads before confirming; names the entity. */
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
overlay: {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
panel: {
|
||||
width: "100%",
|
||||
maxWidth: "26rem",
|
||||
borderRadius: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.dangerBorder,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
padding: "1.5rem",
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
body: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
title: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
message: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1.5rem",
|
||||
},
|
||||
dangerButton: {
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.danger,
|
||||
color: colors.primaryText,
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCancel();
|
||||
}}
|
||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||
>
|
||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
<AriaDialog role="alertdialog" {...stylex.props(styles.body)}>
|
||||
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
|
||||
{title}
|
||||
</Heading>
|
||||
<p {...stylex.props(styles.message)}>{message}</p>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
{...stylex.props(styles.dangerButton, shared.focusRing)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The modal dialog (milestone 23, ruling 4).
|
||||
*
|
||||
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
||||
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
||||
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
interface Props {
|
||||
/** The dialog's accessible name. */
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
overlay: {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
panel: {
|
||||
width: "100%",
|
||||
maxWidth: "28rem",
|
||||
borderRadius: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
padding: "1.5rem",
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
/** The panel already draws the boundary; the dialog's own ring would double it. */
|
||||
body: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
isDismissable
|
||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||
>
|
||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
||||
{children}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* The single-choice picker (milestone 23, ruling 4), replacing every native
|
||||
* `<select>` in the app.
|
||||
*
|
||||
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
|
||||
* ListBoxItem; those parts are the Select, not extra components adopted beyond
|
||||
* ruling 4's scope, and nothing outside this file imports them.
|
||||
*
|
||||
* Keys are strings because a `<select>`'s value was a string. A call site that
|
||||
* models an id converts on both edges.
|
||||
*/
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
import { styles as shared } from "./styles";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: readonly SelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** The visible label. Omit it only when `aria-label` names the control. */
|
||||
label?: string;
|
||||
"aria-label"?: string;
|
||||
/**
|
||||
* `field` matches a full-width form input, `compactField` the smaller one a
|
||||
* dialog uses, `inline` a control sitting in a row of other controls.
|
||||
*/
|
||||
variant?: "field" | "compactField" | "inline";
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
root: {
|
||||
display: "block",
|
||||
},
|
||||
label: {
|
||||
display: "block",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
trigger: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.5rem",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
},
|
||||
compact: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
|
||||
value: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
chevron: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
popover: {
|
||||
width: "var(--trigger-width)",
|
||||
maxHeight: "16rem",
|
||||
overflowY: "auto",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
listBox: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
item: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
cursor: "pointer",
|
||||
outlineStyle: "none",
|
||||
},
|
||||
itemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
},
|
||||
itemSelected: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
|
||||
const base = variant === "field" ? shared.input : shared.smallInput;
|
||||
const block = variant === "compactField" ? styles.compact : null;
|
||||
return (
|
||||
<AriaSelect
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
onChange={(key) => onChange(String(key ?? ""))}
|
||||
{...stylex.props(styles.root)}
|
||||
>
|
||||
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
|
||||
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
|
||||
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
|
||||
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
|
||||
▾
|
||||
</span>
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<ListBox {...stylex.props(styles.listBox)}>
|
||||
{options.map((option) => (
|
||||
<ListBoxItem
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
stylex.props(
|
||||
styles.item,
|
||||
isSelected && styles.itemSelected,
|
||||
isFocused && styles.itemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* The tab switcher (milestone 23, ruling 4).
|
||||
*
|
||||
* The hand-rolled version spelled the ARIA attributes by hand but had no
|
||||
* keyboard navigation; React Aria brings arrow-key movement and roving
|
||||
* tabindex with the same roles. State stays inside RAC — no caller needs to
|
||||
* read which tab is open.
|
||||
*
|
||||
* RAC exposes state as render-prop booleans, so every rule below is a
|
||||
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
export interface TabSpec {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The tab list's accessible name. */
|
||||
label: string;
|
||||
tabs: readonly TabSpec[];
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
list: {
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tab: {
|
||||
marginBottom: -1,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: "transparent",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
fontWeight: 500,
|
||||
color: colors.textMuted,
|
||||
cursor: "pointer",
|
||||
},
|
||||
tabSelected: {
|
||||
borderBottomColor: colors.primary,
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
tabHovered: {
|
||||
color: colors.text,
|
||||
},
|
||||
/**
|
||||
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
|
||||
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
|
||||
*/
|
||||
tabFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
panel: {
|
||||
outlineStyle: "none",
|
||||
},
|
||||
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
||||
root: {
|
||||
display: "block",
|
||||
},
|
||||
});
|
||||
|
||||
export default function Tabs({ label, tabs }: Props) {
|
||||
return (
|
||||
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
|
||||
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
className={({ isSelected, isHovered, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.tab,
|
||||
isHovered && !isSelected && styles.tabHovered,
|
||||
isSelected && styles.tabSelected,
|
||||
isFocusVisible && styles.tabFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
{tab.content}
|
||||
</TabPanel>
|
||||
))}
|
||||
</AriaTabs>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { useCrudForm } from "./useCrudForm";
|
||||
|
||||
interface Row {
|
||||
@@ -41,6 +42,50 @@ function setup() {
|
||||
return { hook, created, updated, removed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The delete path is only finished when a real dialog drives it, so this
|
||||
* harness wires the hook's pending-delete state to the component operators
|
||||
* actually see (ruling 5). `window.confirm` no longer exists to stub.
|
||||
*/
|
||||
function DeleteHarness({ onRemoved }: { onRemoved: (id: number) => void }) {
|
||||
const { onDelete, pendingDelete, confirmPendingDelete, cancelPendingDelete } = useCrudForm<Row, string>({
|
||||
create: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
|
||||
remove: (_qc: QC) => ({
|
||||
mutationFn: async (id: number) => {
|
||||
onRemoved(id);
|
||||
},
|
||||
}),
|
||||
confirmDelete: (row) => `Delete "${row.name}"?`,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => onDelete({ id: 3, name: "gamma" })}>
|
||||
Delete gamma
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete row"
|
||||
message={pendingDelete?.message ?? ""}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmPendingDelete}
|
||||
onCancel={cancelPendingDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDeleteHarness() {
|
||||
const removed: number[] = [];
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DeleteHarness onRemoved={(id) => removed.push(id)} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return removed;
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test("the create form submits through the create mutation and closes", async () => {
|
||||
@@ -96,18 +141,34 @@ test("openForm clears a stale create error so the reopened form starts clean", a
|
||||
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
|
||||
});
|
||||
|
||||
test("delete asks for confirmation and only removes when confirmed", async () => {
|
||||
test("onDelete opens the confirmation instead of removing the row", () => {
|
||||
const { hook, removed } = setup();
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal("confirm", confirm);
|
||||
|
||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
|
||||
|
||||
expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' });
|
||||
expect(removed).toEqual([]);
|
||||
});
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
|
||||
test("cancelling the confirmation dialog leaves the row alone", async () => {
|
||||
const removed = renderDeleteHarness();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain('Delete "gamma"?');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(removed).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming the dialog removes the row by id", async () => {
|
||||
const removed = renderDeleteHarness();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
|
||||
await screen.findByRole("alertdialog");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() => expect(removed).toEqual([3]));
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
test("closeForm closes whichever form is open", () => {
|
||||
|
||||
@@ -4,20 +4,28 @@ import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions
|
||||
/** Which form is open: none, the create form, or the edit form for one row. */
|
||||
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
|
||||
|
||||
/** The row a confirmation dialog is open for, and the message it shows. */
|
||||
export interface PendingDelete<Entity> {
|
||||
entity: Entity;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
|
||||
|
||||
export interface CrudFormSpec<Entity extends { id: number }, Input> {
|
||||
create: MutationFactory<Input>;
|
||||
update: MutationFactory<{ id: number; input: Input }>;
|
||||
remove: MutationFactory<number>;
|
||||
/** The `window.confirm` text shown before a delete. */
|
||||
/** The message the confirmation dialog shows before a delete. */
|
||||
confirmDelete: (entity: Entity) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
|
||||
* three mutations, the open-form state, and the three handlers. The field JSX
|
||||
* and the table stay in the caller.
|
||||
* three mutations, the open-form state, the pending-delete state and the
|
||||
* handlers. The field JSX, the table and the confirmation dialog stay in the
|
||||
* caller: `onDelete` opens the confirmation instead of deciding anything, so
|
||||
* the hook never reaches for `window.confirm` (ruling 5).
|
||||
*/
|
||||
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -25,6 +33,7 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
|
||||
const update = useMutation(spec.update(queryClient));
|
||||
const remove = useMutation(spec.remove(queryClient));
|
||||
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete<Entity> | null>(null);
|
||||
|
||||
function openForm(next: CrudFormState<Entity>) {
|
||||
create.reset();
|
||||
@@ -46,9 +55,30 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
|
||||
}
|
||||
|
||||
function onDelete(entity: Entity) {
|
||||
if (!window.confirm(spec.confirmDelete(entity))) return;
|
||||
remove.mutate(entity.id);
|
||||
setPendingDelete({ entity, message: spec.confirmDelete(entity) });
|
||||
}
|
||||
|
||||
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
|
||||
function confirmPendingDelete() {
|
||||
if (pendingDelete === null) return;
|
||||
remove.mutate(pendingDelete.entity.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
function cancelPendingDelete() {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
return {
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
form,
|
||||
openForm,
|
||||
closeForm,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
pendingDelete,
|
||||
confirmPendingDelete,
|
||||
cancelPendingDelete,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user