Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ semver-governed public surface — a breaking change to a seam is a major bump.

## [Unreleased]

## [0.6.10] - 2026-07-30

### Added
- **Shared confirm/prompt dialogs — no more `window.confirm`/`prompt`.**
`<DialogProvider>` (mounted by `AppShell`) + the `useConfirm()` / `usePrompt()`
hooks give styled, on-brand modals in place of the browser's native dialogs;
all core submit/cancel/delete/discard prompts now use them. Also
**`<ConfirmButton>`** — an inline arm-to-confirm for low-stakes in-row actions:
first click splits the control into *Cancel | Confirm* with Cancel taking the
trigger's spot, so an accidental double-click cancels (nothing happens); it
auto-collapses. All exported from the package.
- **Pagination on top of the list too, with a typeable page number.** The
`DocumentList` pager now renders above *and* below the table (no scrolling to
the bottom to page long lists), and the current page is an editable field —
type a number + Enter to jump (clamped to 1..last).

## [0.6.9] - 2026-07-29

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lambda-development/erp-core",
"version": "0.6.9",
"version": "0.6.10",
"description": "Frontend core of Lambda ERP — app shell, document/master pages, chat UI, reports, and extension registries.",
"license": "Apache-2.0",
"repository": {
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/components/document/document-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Button } from "@/components/ui/button";
import { useConfirm } from "@/components/ui/dialog";

interface ConversionAction {
label: string;
Expand All @@ -22,14 +23,25 @@ export function DocumentActions({
conversions = [],
saving,
}: DocumentActionsProps) {
const handleSubmit = () => {
if (window.confirm("Are you sure you want to submit this document? Once submitted it cannot be edited.")) {
const confirm = useConfirm();

const handleSubmit = async () => {
if (await confirm({
title: "Submit document?",
body: "Once submitted it cannot be edited.",
confirmLabel: "Submit",
})) {
onSubmit();
}
};

const handleCancel = () => {
if (window.confirm("Are you sure you want to cancel this document? This action cannot be undone.")) {
const handleCancel = async () => {
if (await confirm({
title: "Cancel document?",
body: "This action cannot be undone.",
confirmLabel: "Cancel document",
danger: true,
})) {
onCancel();
}
};
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ArrowLeft } from "lucide-react";
import { ChatProvider, useChat } from "@/components/chat/chat-provider";
import { DialogProvider } from "@/components/ui/dialog";
import { Sidebar, FLASH_STYLES } from "@/components/layout/sidebar";
import { useAuth } from "@/contexts/auth-context";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -62,9 +63,11 @@ function deriveBackPath(pathname: string): string | null {

export function AppShell() {
return (
<ChatProvider>
<AppShellContent />
</ChatProvider>
<DialogProvider>
<ChatProvider>
<AppShellContent />
</ChatProvider>
</DialogProvider>
);
}

Expand Down
90 changes: 90 additions & 0 deletions frontend/src/components/ui/confirm-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Inline arm-to-confirm button for low-stakes, in-row actions (e.g. removing a
// row from a list). On first click it splits in place into "Cancel | Confirm",
// with **Cancel occupying the trigger's original position** — so an accidental
// double-click's second click lands on Cancel and nothing happens. It
// auto-collapses after `timeout` ms or on pointer-leave.
//
// For heavier or destructive actions (deleting a whole record), prefer the
// modal useConfirm() from dialog.tsx instead — a transient inline split in a
// busy toolbar is more clutter than a focused dialog.
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";

interface ConfirmButtonProps {
onConfirm: () => void;
/** Resting trigger content (icon or text). */
children: React.ReactNode;
confirmLabel?: React.ReactNode;
cancelLabel?: React.ReactNode;
title?: string; // tooltip on the resting trigger
danger?: boolean; // red confirm (default true)
disabled?: boolean;
className?: string; // applied to the resting trigger
timeout?: number; // auto-collapse ms (default 4000)
}

export function ConfirmButton({
onConfirm,
children,
confirmLabel,
cancelLabel,
title,
danger = true,
disabled,
className,
timeout = 4000,
}: ConfirmButtonProps) {
const { t } = useTranslation();
const [armed, setArmed] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

useEffect(() => {
if (!armed) return;
timer.current = setTimeout(() => setArmed(false), timeout);
return () => clearTimeout(timer.current);
}, [armed, timeout]);

if (!armed) {
return (
<button
type="button"
disabled={disabled}
title={title}
onClick={() => setArmed(true)}
className={className}
>
{children}
</button>
);
}

return (
<span
className="inline-flex items-center gap-1"
onMouseLeave={() => setArmed(false)}
>
{/* Cancel sits where the trigger was, so a double-click cancels safely. */}
<button
type="button"
onClick={() => setArmed(false)}
className="rounded-md px-2 py-1 text-xs text-fg-muted hover:text-fg"
>
{cancelLabel ?? t("common.cancel", { defaultValue: "Cancel" })}
</button>
<button
type="button"
onClick={() => {
setArmed(false);
onConfirm();
}}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium text-white",
danger ? "bg-red-600 hover:bg-red-700" : "bg-brand hover:bg-brand/90",
)}
>
{confirmLabel ?? t("common.delete", { defaultValue: "Delete" })}
</button>
</span>
);
}
140 changes: 140 additions & 0 deletions frontend/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Shared, styled confirm/prompt dialogs — a replacement for window.confirm /
// window.prompt. Mount <DialogProvider> once (AppShell does), then anywhere in
// the tree call the useConfirm() / usePrompt() hooks:
//
// const confirm = useConfirm();
// if (await confirm({ title: "Delete?", danger: true })) { ... }
//
// const prompt = usePrompt();
// const name = await prompt({ title: "Rename", defaultValue: current });
//
// For low-stakes, in-row actions prefer <ConfirmButton> (confirm-button.tsx),
// which arms inline instead of opening a modal.
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

type ConfirmOpts = {
title: string;
body?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
};

type PromptOpts = ConfirmOpts & {
defaultValue?: string;
placeholder?: string;
inputType?: "text" | "date" | "number";
};

type DialogState =
| { kind: "confirm"; opts: ConfirmOpts; resolve: (v: boolean) => void }
| { kind: "prompt"; opts: PromptOpts; resolve: (v: string | null) => void }
| null;

type DialogApi = {
confirm: (opts: ConfirmOpts) => Promise<boolean>;
prompt: (opts: PromptOpts) => Promise<string | null>;
};

const DialogContext = createContext<DialogApi | null>(null);

export function DialogProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [state, setState] = useState<DialogState>(null);
const [value, setValue] = useState("");

const confirm = useCallback(
(opts: ConfirmOpts) =>
new Promise<boolean>((resolve) => setState({ kind: "confirm", opts, resolve })),
[],
);
const prompt = useCallback(
(opts: PromptOpts) =>
new Promise<string | null>((resolve) => {
setValue(opts.defaultValue ?? "");
setState({ kind: "prompt", opts, resolve });
}),
[],
);

const api = useMemo<DialogApi>(() => ({ confirm, prompt }), [confirm, prompt]);

const settle = (result: boolean | string | null) => {
if (!state) return;
(state.resolve as (v: boolean | string | null) => void)(result);
setState(null);
};
const onConfirm = () => settle(state?.kind === "prompt" ? value : true);
const onCancel = () => settle(state?.kind === "prompt" ? null : false);

return (
<DialogContext.Provider value={api}>
{children}
{state && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="absolute inset-0 bg-black/40 backdrop-blur-[1px]" onClick={onCancel} />
<div className="relative w-full max-w-md rounded-xl bg-surface p-5 shadow-card ring-1 ring-line">
<h2 className="text-lg font-semibold text-fg">{state.opts.title}</h2>
{state.opts.body != null && (
<div className="mt-2 text-sm text-fg-muted">{state.opts.body}</div>
)}
{state.kind === "prompt" && (
<Input
autoFocus
type={state.opts.inputType ?? "text"}
placeholder={state.opts.placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onConfirm();
}}
className="mt-3"
/>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>
{state.opts.cancelLabel ?? t("common.cancel", { defaultValue: "Cancel" })}
</Button>
<Button variant={state.opts.danger ? "danger" : "primary"} onClick={onConfirm}>
{state.opts.confirmLabel ?? t("common.ok", { defaultValue: "OK" })}
</Button>
</div>
</div>
</div>
)}
</DialogContext.Provider>
);
}

function useDialog(): DialogApi {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error("useConfirm/usePrompt must be used within <DialogProvider>");
return ctx;
}

/** Returns confirm(opts) => Promise<boolean>. Replaces window.confirm. */
export function useConfirm() {
return useDialog().confirm;
}

/** Returns prompt(opts) => Promise<string|null>. Replaces window.prompt. */
export function usePrompt() {
return useDialog().prompt;
}
6 changes: 6 additions & 0 deletions frontend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export { registerComponent, getComponent } from "./lib/component-registry";
// Prev/next record navigation (works on any doctype; custom detail pages can
// drop in <DocPager slug=… name=… onSave=… />). The list-context store lets it
// follow the list the user came from.
// Shared confirm/prompt dialogs (replace window.confirm/prompt). DialogProvider
// is mounted by AppShell; call useConfirm()/usePrompt() anywhere below it.
// ConfirmButton is the inline arm-to-confirm variant for in-row actions.
export { DialogProvider, useConfirm, usePrompt } from "./components/ui/dialog";
export { ConfirmButton } from "./components/ui/confirm-button";

export { DocPager } from "./components/document/doc-pager";
export { setListContext, getListContext } from "./lib/doc-list-context";
export type { ListContext } from "./lib/doc-list-context";
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/pages/admin/users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { usePrompt } from "@/components/ui/dialog";

export default function UsersPage() {
const { user: currentUser } = useAuth();
Expand Down Expand Up @@ -50,16 +51,17 @@ export default function UsersPage() {
});

const [copiedToken, setCopiedToken] = useState<string | null>(null);
const prompt = usePrompt();
const inviteLink = (token: string) => `${window.location.origin}/login?invite=${token}`;
const copyInviteLink = async (token: string) => {
try {
await navigator.clipboard.writeText(inviteLink(token));
setCopiedToken(token);
setTimeout(() => setCopiedToken((t) => (t === token ? null : t)), 2000);
} catch {
// Clipboard API unavailable (e.g. non-HTTPS): fall back to a prompt so
// the admin can still grab the link.
window.prompt("Invite link", inviteLink(token));
// Clipboard API unavailable (e.g. non-HTTPS): show the link in a dialog
// (a selectable field) so the admin can still copy it manually.
void prompt({ title: "Invite link", body: "Copy this link:", defaultValue: inviteLink(token) });
}
};

Expand Down
Loading