mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add in-app confirmation dialog replacing window.confirm
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { confirm, useConfirmStore } from './ConfirmStore';
|
||||
|
||||
afterEach(() => {
|
||||
// Resolve any dangling request so promises never leak between tests.
|
||||
useConfirmStore.getState().resolve(false);
|
||||
useConfirmStore.setState({ request: null });
|
||||
});
|
||||
|
||||
describe('ConfirmStore', () => {
|
||||
it('opens a request carrying the provided options', () => {
|
||||
void confirm({ title: 'Delete snippet', message: 'Cannot be undone.', danger: true });
|
||||
const req = useConfirmStore.getState().request;
|
||||
expect(req).not.toBeNull();
|
||||
expect(req?.title).toBe('Delete snippet');
|
||||
expect(req?.message).toBe('Cannot be undone.');
|
||||
expect(req?.danger).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves true when accepted and clears the request', async () => {
|
||||
const p = confirm({ title: 'T', message: 'M' });
|
||||
useConfirmStore.getState().resolve(true);
|
||||
await expect(p).resolves.toBe(true);
|
||||
expect(useConfirmStore.getState().request).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves false when cancelled and clears the request', async () => {
|
||||
const p = confirm({ title: 'T', message: 'M' });
|
||||
useConfirmStore.getState().resolve(false);
|
||||
await expect(p).resolves.toBe(false);
|
||||
expect(useConfirmStore.getState().request).toBeNull();
|
||||
});
|
||||
|
||||
it('resolve() with no open request is a no-op', () => {
|
||||
expect(() => useConfirmStore.getState().resolve(true)).not.toThrow();
|
||||
expect(useConfirmStore.getState().request).toBeNull();
|
||||
});
|
||||
|
||||
it('opening a second confirmation cancels the first (no hung awaiter)', async () => {
|
||||
const first = confirm({ title: 'First', message: 'M' });
|
||||
const second = confirm({ title: 'Second', message: 'M' });
|
||||
|
||||
// The first awaiter settles false immediately; the second is now open.
|
||||
await expect(first).resolves.toBe(false);
|
||||
expect(useConfirmStore.getState().request?.title).toBe('Second');
|
||||
|
||||
useConfirmStore.getState().resolve(true);
|
||||
await expect(second).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Confirmation dialogs — the in-app replacement for `window.confirm`
|
||||
* (docs/architecture/03 → "Confirmation & alert dialogs").
|
||||
*
|
||||
* This is a deliberately separate, lighter layer from the named feature-modal
|
||||
* system (registry/coordinator/shell). Feature modals are a fixed, registered,
|
||||
* URL-navigable set with "at most one open at a time". A confirmation is the
|
||||
* opposite: ephemeral, content-on-call, and allowed to sit *above* an open
|
||||
* feature modal (the discard-changes prompt appears over the Datasets modal).
|
||||
* So it gets its own store and its own z-layer rather than a `ModalName` entry.
|
||||
*
|
||||
* The API is promise-based and callable from anywhere — React components and
|
||||
* non-React code alike (the modal coordinator's `setConfirm` seam will resolve
|
||||
* to `confirm()` here once the feature-modal system lands). `confirm(opts)`
|
||||
* returns a Promise<boolean>: `true` if the user accepted, `false` if they
|
||||
* cancelled / dismissed.
|
||||
*
|
||||
* The store holds only request state; rendering lives in ConfirmDialog.tsx.
|
||||
* Keeping the resolve/replace logic here makes it unit-testable without a DOM.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
/** Short dialog title, e.g. "Delete snippet". */
|
||||
title: string;
|
||||
/** Body text; states the consequence ("This cannot be undone."). */
|
||||
message: string;
|
||||
/** Primary (accept) button label. Defaults to "Confirm". */
|
||||
confirmLabel?: string;
|
||||
/** Secondary (cancel) button label. Defaults to "Cancel". */
|
||||
cancelLabel?: string;
|
||||
/**
|
||||
* Destructive intent → Carbon "danger" styling (red primary) and a
|
||||
* cancel-defaulted focus so an accidental Enter doesn't destroy anything.
|
||||
*/
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmRequest extends ConfirmOptions {
|
||||
/** Settles the promise returned by `confirm()` for this request. */
|
||||
resolve: (accepted: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ConfirmState {
|
||||
/** The open request, or null when no dialog is showing. */
|
||||
request: ConfirmRequest | null;
|
||||
/** Open a confirmation and resolve when the user chooses. */
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
/** Settle the open request with the user's choice and close the dialog. */
|
||||
resolve: (accepted: boolean) => void;
|
||||
}
|
||||
|
||||
export const useConfirmStore = create<ConfirmState>((set, get) => ({
|
||||
request: null,
|
||||
|
||||
confirm: (options) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
// Only one confirmation at a time. If one is somehow already open,
|
||||
// treat it as cancelled so its awaiter never hangs, then replace it.
|
||||
const prev = get().request;
|
||||
if (prev) prev.resolve(false);
|
||||
set({ request: { ...options, resolve } });
|
||||
}),
|
||||
|
||||
resolve: (accepted) => {
|
||||
const req = get().request;
|
||||
if (!req) return; // nothing open — no-op
|
||||
set({ request: null });
|
||||
req.resolve(accepted);
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Imperative entry point for non-React callers (e.g. the future modal
|
||||
* coordinator's `setConfirm`). Components may use this too, but typically
|
||||
* subscribe to `useConfirmStore` for rendering.
|
||||
*/
|
||||
export const confirm = (options: ConfirmOptions): Promise<boolean> =>
|
||||
useConfirmStore.getState().confirm(options);
|
||||
Reference in New Issue
Block a user