refactor(ui): remove Markdown rendering from ConfirmDialog component

- Removed Markdown rendering capability from ConfirmDialog component
- Removed marked library dependency from package.json and lockfile
- Updated all component usages to use plain text descriptions
- Converted irreversible action warnings from Markdown to plain text
- Simplified component API by removing descriptionMarkdown prop
- Updated ConfirmDialog README to reflect simplified implementation
- Retained DOMPurify dependency for other components that need it
- Updated en.json translations to remove Markdown formatting
pull/5111/head
Nic Luckie 3 weeks ago
parent 72fcbf7e10
commit ea3b6a7c77

@ -40,7 +40,6 @@
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"lucide-react": "^0.486.0",
"marked": "^16.3.0",
"mermaid": "^11.11.0",
"mime": "^4.1.0",
"mobx": "^6.13.7",

@ -104,9 +104,6 @@ importers:
lucide-react:
specifier: ^0.486.0
version: 0.486.0(react@18.3.1)
marked:
specifier: ^16.3.0
version: 16.3.0
mermaid:
specifier: ^11.11.0
version: 11.11.0
@ -2731,11 +2728,6 @@ packages:
engines: {node: '>= 18'}
hasBin: true
marked@16.3.0:
resolution: {integrity: sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@ -6232,8 +6224,6 @@ snapshots:
marked@15.0.12: {}
marked@16.3.0: {}
math-intrinsics@1.1.0: {}
mdn-data@2.0.14: {}

@ -2,7 +2,7 @@
## Overview
`ConfirmDialog` standardizes confirmation flows across the app. It replaces adhoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations and optional Markdown descriptions.
`ConfirmDialog` standardizes confirmation flows across the app. It replaces adhoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations.
## Key Features
@ -11,19 +11,15 @@
- Blocks dismissal while async confirm is pending
- Clear separation of title (action) vs description (context)
### 2. Markdown Support
- Optional `descriptionMarkdown` renders localized Markdown
- Sanitized via `DOMPurify` after `marked` parsing
### 3. Async-Aware
### 2. Async-Aware
- Accepts sync or async `onConfirm`
- Auto-closes on resolve; remains open on error for retry / toast
### 4. Internationalization Ready
### 3. Internationalization Ready
- All labels / text provided by caller through i18n hook
- Supports interpolation for dynamic context
### 5. Minimal Surface, Easy Extension
### 4. Minimal Surface, Easy Extension
- Lightweight API (few required props)
- Style hook via `.container` class (SCSS module)
@ -32,14 +28,12 @@
```
ConfirmDialog
├── State: loading (tracks pending confirm action)
├── Markdown pipeline: marked → DOMPurify → safe HTML (if descriptionMarkdown)
├── Dialog primitives: Header (title + description), Footer (buttons)
└── External control: parent owns open state via onOpenChange
```
## Usage
### Basic
```tsx
import { useTranslate } from "@/utils/i18n";
import ConfirmDialog from "@/components/ConfirmDialog";
@ -47,28 +41,14 @@ import ConfirmDialog from "@/components/ConfirmDialog";
const t = useTranslate();
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={t("memo.delete-confirm")}
description={t("memo.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDelete}
confirmVariant="destructive"
/>;
```
### With Markdown Description & Interpolation
```tsx
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={t("setting.shortcut.delete-confirm")}
descriptionMarkdown={t("setting.shortcut.delete-confirm-markdown", { title: shortcut.title })}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={deleteShortcut}
confirmVariant="destructive"
open={open}
onOpenChange={setOpen}
title={t("memo.delete-confirm")}
description={t("memo.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDelete}
confirmVariant="destructive"
/>;
```
@ -79,25 +59,24 @@ const t = useTranslate();
| `open` | `boolean` | Yes | `true` (visible) / `false` (hidden) |
| `onOpenChange` | `(open: boolean) => void` | Yes | Callback receiving next state; should update parent state |
| `title` | `React.ReactNode` | Yes | Short localized action summary (text / node) |
| `description` | `React.ReactNode` | No | Plain content; ignored if `descriptionMarkdown` provided |
| `descriptionMarkdown` | `string` | No | Localized Markdown string (sanitized) |
| `description` | `React.ReactNode` | No | Optional contextual message |
| `confirmLabel` | `string` | Yes | Non-empty localized action text (12 words) |
| `cancelLabel` | `string` | Yes | Localized cancel label |
| `onConfirm` | `() => void \| Promise<void>` | Yes | Sync or async handler; resolve = close, reject = stay open |
| `confirmVariant` | `"default" \| "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions |
| `onConfirm` | `() => void | Promise<void>` | Yes | Sync or async handler; resolve = close, reject = stay open |
| `confirmVariant` | `"default" | "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions |
## Benefits vs Previous Implementation
### Before (window.confirm / adhoc dialogs)
- Blocking native prompt, inconsistent styling
- No async progress handling
- No Markdown / rich formatting
- No rich formatting
- Hard to localize consistently
### After (ConfirmDialog)
- Unified styling + accessibility semantics
- Async-safe with loading state shielding
- Markdown or plain description flexibility
- Plain description flexibility
- i18n-first via externalized labels
## Technical Implementation Details
@ -105,25 +84,18 @@ const t = useTranslate();
### Async Handling
```tsx
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm(); // resolve -> close
onOpenChange(false);
} catch (e) {
console.error(e); // remain open for retry
} finally {
setLoading(false);
}
setLoading(true);
try {
await onConfirm(); // resolve -> close
onOpenChange(false);
} catch (e) {
console.error(e); // remain open for retry
} finally {
setLoading(false);
}
};
```
### Markdown Sanitization
```tsx
const descriptionHtml = descriptionMarkdown
? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown)))
: null;
```
### Close Guard
```tsx
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)} />
@ -131,13 +103,11 @@ const descriptionHtml = descriptionMarkdown
## Browser / Environment Support
- Works anywhere the existing `Dialog` primitives work (modern browsers)
- Requires DOM for Markdown + sanitization (not SSR executed unless guarded)
- No ResizeObserver / layout dependencies
## Performance Considerations
1. Markdown parse + sanitize runs only when `descriptionMarkdown` changes
2. Minimal renders: loading state toggles once per confirm attempt
3. No portal churn—relies on underlying dialog infra
1. Minimal renders: loading state toggles once per confirm attempt
2. No portal churn—relies on underlying dialog infra
## Future Enhancements
1. Severity icon / header accent

@ -1,5 +1,3 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -11,10 +9,8 @@ export interface ConfirmDialogProps {
onOpenChange: (open: boolean) => void;
/** Title content (plain text or React nodes) */
title: React.ReactNode;
/** Optional description as React nodes (ignored if descriptionMarkdown provided) */
/** Optional description (plain text or React nodes) */
description?: React.ReactNode;
/** Optional description in Markdown. Sanitized & rendered as HTML if provided */
descriptionMarkdown?: string;
/** Confirm / primary action button label */
confirmLabel: string;
/** Cancel button label */
@ -26,8 +22,8 @@ export interface ConfirmDialogProps {
}
/**
* Accessible confirmation dialog with optional Markdown description.
* - Renders description from either React nodes or sanitized Markdown
* Accessible confirmation dialog.
* - Renders optional description content
* - Prevents closing while async confirm action is in-flight
* - Minimal opinionated styling; leverages existing UI primitives
*/
@ -36,7 +32,6 @@ export default function ConfirmDialog({
onOpenChange,
title,
description,
descriptionMarkdown,
confirmLabel,
cancelLabel,
onConfirm,
@ -59,26 +54,12 @@ export default function ConfirmDialog({
}
};
// Prepare sanitized HTML if Markdown was provided, memoized for performance
const descriptionHtml = React.useMemo(() => {
return typeof descriptionMarkdown === "string" ? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown))) : null;
}, [descriptionMarkdown]);
return (
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{/*
Rendering sanitized Markdown as HTML.
This is considered safe because DOMPurify removes any potentially dangerous content.
Ensure that Markdown input is trusted or validated upstream.
*/}
{descriptionHtml ? (
<DialogDescription dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
) : description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>

@ -228,7 +228,7 @@ const MemoActionMenu = observer((props: Props) => {
onOpenChange={setDeleteDialogOpen}
title={t("memo.delete-confirm")}
confirmLabel={t("common.delete")}
descriptionMarkdown={t("memo.delete-confirm-description")}
description={t("memo.delete-confirm-description")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteMemo}
confirmVariant="destructive"

@ -151,7 +151,7 @@ const AccessTokenSection = () => {
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.access-token-section.access-token-deletion", { description: deleteTarget.description }) : ""}
descriptionMarkdown={t("setting.access-token-section.access-token-deletion-description")}
description={t("setting.access-token-section.access-token-deletion-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteAccessToken}

@ -201,7 +201,7 @@ const MemberSection = observer(() => {
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.member-section.delete-warning", { username: deleteTarget.username }) : ""}
descriptionMarkdown={deleteTarget ? t("setting.member-section.delete-warning-description") : ""}
description={deleteTarget ? t("setting.member-section.delete-warning-description") : ""}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteUser}

@ -162,7 +162,7 @@ const UserSessionsSection = () => {
})
: ""
}
descriptionMarkdown={revokeTarget ? t("setting.user-sessions-section.session-revocation-description") : ""}
description={revokeTarget ? t("setting.user-sessions-section.session-revocation-description") : ""}
confirmLabel={t("setting.user-sessions-section.revoke-session-button")}
cancelLabel={t("common.cancel")}
onConfirm={confirmRevokeSession}

@ -33,8 +33,10 @@ const WebhookSection = () => {
const handleCreateWebhookDialogConfirm = async () => {
const webhooks = await listWebhooks();
const name = webhooks[webhooks.length - 1]?.displayName || "";
setWebhooks(webhooks);
setIsCreateWebhookDialogOpen(false);
toast.success(t("setting.webhook-section.create-dialog.create-webhook-success", { name }));
};
const handleDeleteWebhook = async (webhook: UserWebhook) => {
@ -45,8 +47,8 @@ const WebhookSection = () => {
if (!deleteTarget) return;
await userServiceClient.deleteUserWebhook({ name: deleteTarget.name });
setWebhooks(webhooks.filter((item) => item.name !== deleteTarget.name));
toast.success(t("setting.webhook-section.delete-dialog.delete-webhook-success", { name: deleteTarget?.displayName || "" }));
setDeleteTarget(undefined);
toast.success(t("setting.webhook-section.delete-dialog.delete-webhook-success", { name: deleteTarget.displayName }));
};
return (
@ -124,7 +126,7 @@ const WebhookSection = () => {
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.webhook-section.delete-dialog.delete-webhook-title", { name: deleteTarget?.displayName || "" })}
descriptionMarkdown={t("setting.webhook-section.delete-dialog.delete-webhook-description")}
description={t("setting.webhook-section.delete-dialog.delete-webhook-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteWebhook}

@ -144,7 +144,7 @@
"copy-link": "Copy Link",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "**THIS ACTION IS IRREVERSIBLE**\n\nAttachments, links, and references will also be removed.",
"delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.",
"direction": "Direction",
"direction-asc": "Ascending",
"direction-desc": "Descending",
@ -240,11 +240,11 @@
"setting": {
"access-token-section": {
"access-token-copied-to-clipboard": "Access token copied to clipboard",
"access-token-deletion": "Are you sure you want to delete access token \"{{description}}\"?",
"access-token-deletion-description": "**THIS ACTION IS IRREVERSIBLE**\n\nYou will need to update any services using this token to use a new token.",
"access-token-deleted": "Access token \"{{description}}\" deleted",
"access-token-deletion": "Are you sure you want to delete access token `{{description}}`?",
"access-token-deletion-description": "This action is irreversible. You will need to update any services using this token to use a new token.",
"access-token-deleted": "Access token `{{description}}` deleted",
"create-dialog": {
"access-token-created": "Access token \"{{description}}\" created",
"access-token-created": "Access token `{{description}}` created",
"access-token-created-default": "Access token created",
"create-access-token": "Create Access Token",
"created-at": "Created At",
@ -302,7 +302,7 @@
"create-a-member": "Create a member",
"delete-member": "Delete Member",
"delete-warning": "Are you sure you want to delete {{username}}?",
"delete-warning-description": "**THIS ACTION IS IRREVERSIBLE**",
"delete-warning-description": "THIS ACTION IS IRREVERSIBLE",
"delete-success": "{{username}} deleted successfully",
"user": "User"
},
@ -324,15 +324,15 @@
"theme": "Theme"
},
"shortcut": {
"delete-confirm": "Are you sure you want to delete shortcut \"{{title}}\"?",
"delete-success": "Shortcut \"{{title}}\" deleted successfully"
"delete-confirm": "Are you sure you want to delete shortcut `{{title}}`?",
"delete-success": "Shortcut `{{title}}` deleted successfully"
},
"sso": "SSO",
"sso-section": {
"authorization-endpoint": "Authorization endpoint",
"client-id": "Client ID",
"client-secret": "Client secret",
"confirm-delete": "Are you sure you want to delete \"{{name}}\" SSO configuration? THIS ACTION IS IRREVERSIBLE",
"confirm-delete": "Are you sure you want to delete `{{name}}` SSO configuration? THIS ACTION IS IRREVERSIBLE",
"create-sso": "Create SSO",
"custom": "Custom",
"delete-sso": "Confirm delete",
@ -385,7 +385,7 @@
"url-prefix-placeholder": "Custom URL prefix, optional",
"url-suffix": "URL suffix",
"url-suffix-placeholder": "Custom URL suffix, optional",
"warning-text": "Are you sure you want to delete storage service \"{{name}}\"? THIS ACTION IS IRREVERSIBLE"
"warning-text": "Are you sure you want to delete storage service `{{name}}`? THIS ACTION IS IRREVERSIBLE"
},
"system": "System",
"system-section": {
@ -402,7 +402,7 @@
},
"disable-markdown-shortcuts-in-editor": "Disable Markdown shortcuts in editor",
"disable-password-login": "Disable password login",
"disable-password-login-final-warning": "Please type \"CONFIRM\" if you know what you are doing.",
"disable-password-login-final-warning": "Please type `CONFIRM` if you know what you are doing.",
"disable-password-login-warning": "This will disable password login for all users. It is not possible to log in without reverting this setting in the database if your configured identity providers fail. Youll also have to be extra careful when removing an identity provider",
"disable-public-memos": "Disable public memos",
"display-with-updated-time": "Display with updated time",
@ -421,13 +421,14 @@
"create-dialog": {
"an-easy-to-remember-name": "An easy-to-remember name",
"create-webhook": "Create webhook",
"create-webhook-success": "Webhook `{{name}}` created",
"edit-webhook": "Edit webhook",
"payload-url": "Payload URL",
"title": "Title",
"url-example-post-receive": "https://example.com/postreceive"
},
"delete-dialog": {
"delete-webhook-description": "**THIS ACTION IS IRREVERSIBLE**",
"delete-webhook-description": "This action is irreversible.",
"delete-webhook-title": "Are you sure you want to delete webhook `{{name}}`?",
"delete-webhook-success": "Webhook `{{name}}` deleted successfully"
},

Loading…
Cancel
Save