Files
pansy/web/src/components/ui/ConfirmModal.tsx
T
steveandClaude Opus 4.8 9a8382c4a2
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 8m19s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m19s
Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107)
Three tidy-ups from the audit's deferred list:

- Extracted a ConfirmModal primitive (message + Cancel/Confirm, owning the
  busy lock + inline error) and folded the five hand-rolled confirm dialogs
  onto it: DeleteGarden, LeaveGarden, DeletePlant, DeleteSeedLot, ClearBed.
  Each is now just its message + mutation + labels. Bonus: ClearBed now
  shows a failure inline instead of swallowing it. (CopyGarden stays on
  Modal — it has a name field, not a plain confirm.)
- Removed components/PageStub.tsx — dead scaffolding, imported nowhere.
- The garden editor was squeezed into the max-w-5xl reading measure the
  other pages use; the canvas routes (editor + public garden) now go
  edge-to-edge on desktop, and the top bar matches so the brand aligns with
  the editor's left edge. Mobile was already full-width, so it's unchanged.

Verified live: the Delete-garden confirm renders/cancels; the desktop editor
now uses the full viewport width. tsc + vitest + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:00:07 -04:00

80 lines
2.5 KiB
TypeScript

import { useState, type ReactNode } from 'react'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
/**
* A confirm-and-act dialog: a message, then Cancel / Confirm. It owns the shared
* shape every confirmation repeated by hand — the busy lock, the inline error on
* failure (so a 409 like PLANT_IN_USE is shown, not swallowed), and the footer —
* so each caller supplies only its message, its action, and its labels.
*
* onConfirm runs the action; it resolving closes the dialog, it throwing keeps
* the dialog open with the error and re-enables the button for a retry. This is
* confirmations only — dialogs with their own inputs (a rename, a form) keep
* using Modal directly.
*/
export function ConfirmModal({
title,
children,
confirmLabel,
busyLabel,
confirmVariant = 'danger',
confirmDisabled = false,
errorFallback,
onConfirm,
onClose,
}: {
title: string
/** The body — what's being confirmed. */
children: ReactNode
confirmLabel: string
/** Label while the action is in flight (e.g. "Deleting…"). */
busyLabel: string
confirmVariant?: 'danger' | 'primary'
/** Extra guard beyond busy (e.g. nothing to clear, no current user). */
confirmDisabled?: boolean
/** Message if the action throws something without its own user-facing text. */
errorFallback: string
onConfirm: () => Promise<unknown>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleConfirm() {
setError(null)
setBusy(true)
try {
await onConfirm()
onClose()
} catch (err) {
setError(errorMessage(err, errorFallback))
setBusy(false) // keep the dialog open so the message shows and retry works
}
}
return (
<Modal title={title} onClose={onClose} busy={busy}>
<div className="flex flex-col gap-4">
{children}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
type="button"
variant={confirmVariant}
onClick={handleConfirm}
disabled={busy || confirmDisabled}
>
{busy ? busyLabel : confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}