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", "leaflet": "^1.9.4",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.486.0", "lucide-react": "^0.486.0",
"marked": "^16.3.0",
"mermaid": "^11.11.0", "mermaid": "^11.11.0",
"mime": "^4.1.0", "mime": "^4.1.0",
"mobx": "^6.13.7", "mobx": "^6.13.7",

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

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

@ -1,5 +1,3 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import * as React from "react"; import * as React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -11,10 +9,8 @@ export interface ConfirmDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Title content (plain text or React nodes) */ /** Title content (plain text or React nodes) */
title: React.ReactNode; title: React.ReactNode;
/** Optional description as React nodes (ignored if descriptionMarkdown provided) */ /** Optional description (plain text or React nodes) */
description?: React.ReactNode; description?: React.ReactNode;
/** Optional description in Markdown. Sanitized & rendered as HTML if provided */
descriptionMarkdown?: string;
/** Confirm / primary action button label */ /** Confirm / primary action button label */
confirmLabel: string; confirmLabel: string;
/** Cancel button label */ /** Cancel button label */
@ -26,8 +22,8 @@ export interface ConfirmDialogProps {
} }
/** /**
* Accessible confirmation dialog with optional Markdown description. * Accessible confirmation dialog.
* - Renders description from either React nodes or sanitized Markdown * - Renders optional description content
* - Prevents closing while async confirm action is in-flight * - Prevents closing while async confirm action is in-flight
* - Minimal opinionated styling; leverages existing UI primitives * - Minimal opinionated styling; leverages existing UI primitives
*/ */
@ -36,7 +32,6 @@ export default function ConfirmDialog({
onOpenChange, onOpenChange,
title, title,
description, description,
descriptionMarkdown,
confirmLabel, confirmLabel,
cancelLabel, cancelLabel,
onConfirm, 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 ( return (
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}> <Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
<DialogContent size="sm"> <DialogContent size="sm">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
{/* {description ? <DialogDescription>{description}</DialogDescription> : null}
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}
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}> <Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>

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

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

@ -201,7 +201,7 @@ const MemberSection = observer(() => {
open={!!deleteTarget} open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)} onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={deleteTarget ? t("setting.member-section.delete-warning", { username: deleteTarget.username }) : ""} 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")} confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")} cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteUser} 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")} confirmLabel={t("setting.user-sessions-section.revoke-session-button")}
cancelLabel={t("common.cancel")} cancelLabel={t("common.cancel")}
onConfirm={confirmRevokeSession} onConfirm={confirmRevokeSession}

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

@ -144,7 +144,7 @@
"copy-link": "Copy Link", "copy-link": "Copy Link",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}", "count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo?", "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": "Direction",
"direction-asc": "Ascending", "direction-asc": "Ascending",
"direction-desc": "Descending", "direction-desc": "Descending",
@ -240,11 +240,11 @@
"setting": { "setting": {
"access-token-section": { "access-token-section": {
"access-token-copied-to-clipboard": "Access token copied to clipboard", "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": "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-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", "access-token-deleted": "Access token `{{description}}` deleted",
"create-dialog": { "create-dialog": {
"access-token-created": "Access token \"{{description}}\" created", "access-token-created": "Access token `{{description}}` created",
"access-token-created-default": "Access token created", "access-token-created-default": "Access token created",
"create-access-token": "Create Access Token", "create-access-token": "Create Access Token",
"created-at": "Created At", "created-at": "Created At",
@ -302,7 +302,7 @@
"create-a-member": "Create a member", "create-a-member": "Create a member",
"delete-member": "Delete Member", "delete-member": "Delete Member",
"delete-warning": "Are you sure you want to delete {{username}}?", "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", "delete-success": "{{username}} deleted successfully",
"user": "User" "user": "User"
}, },
@ -324,15 +324,15 @@
"theme": "Theme" "theme": "Theme"
}, },
"shortcut": { "shortcut": {
"delete-confirm": "Are you sure you want to delete shortcut \"{{title}}\"?", "delete-confirm": "Are you sure you want to delete shortcut `{{title}}`?",
"delete-success": "Shortcut \"{{title}}\" deleted successfully" "delete-success": "Shortcut `{{title}}` deleted successfully"
}, },
"sso": "SSO", "sso": "SSO",
"sso-section": { "sso-section": {
"authorization-endpoint": "Authorization endpoint", "authorization-endpoint": "Authorization endpoint",
"client-id": "Client ID", "client-id": "Client ID",
"client-secret": "Client secret", "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", "create-sso": "Create SSO",
"custom": "Custom", "custom": "Custom",
"delete-sso": "Confirm delete", "delete-sso": "Confirm delete",
@ -385,7 +385,7 @@
"url-prefix-placeholder": "Custom URL prefix, optional", "url-prefix-placeholder": "Custom URL prefix, optional",
"url-suffix": "URL suffix", "url-suffix": "URL suffix",
"url-suffix-placeholder": "Custom URL suffix, optional", "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": "System",
"system-section": { "system-section": {
@ -402,7 +402,7 @@
}, },
"disable-markdown-shortcuts-in-editor": "Disable Markdown shortcuts in editor", "disable-markdown-shortcuts-in-editor": "Disable Markdown shortcuts in editor",
"disable-password-login": "Disable password login", "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-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", "disable-public-memos": "Disable public memos",
"display-with-updated-time": "Display with updated time", "display-with-updated-time": "Display with updated time",
@ -421,13 +421,14 @@
"create-dialog": { "create-dialog": {
"an-easy-to-remember-name": "An easy-to-remember name", "an-easy-to-remember-name": "An easy-to-remember name",
"create-webhook": "Create webhook", "create-webhook": "Create webhook",
"create-webhook-success": "Webhook `{{name}}` created",
"edit-webhook": "Edit webhook", "edit-webhook": "Edit webhook",
"payload-url": "Payload URL", "payload-url": "Payload URL",
"title": "Title", "title": "Title",
"url-example-post-receive": "https://example.com/postreceive" "url-example-post-receive": "https://example.com/postreceive"
}, },
"delete-dialog": { "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-title": "Are you sure you want to delete webhook `{{name}}`?",
"delete-webhook-success": "Webhook `{{name}}` deleted successfully" "delete-webhook-success": "Webhook `{{name}}` deleted successfully"
}, },

Loading…
Cancel
Save