refactor(web): migrate content-dialog to base/ui/dialog primitives

Remove the single-caller legacy `base/content-dialog` wrapper and
migrate `AppInfoDetailPanel` to composable `DialogBackdrop`/`DialogPopup`
exports from `base/ui/dialog`, preserving left-side drawer semantics
and slide animation.

- Add `DialogBackdrop` and `DialogPopup` to `base/ui/dialog` so callers
  can compose non-centered overlays without bypassing the abstraction
- Refactor `DialogContent` to reuse the new primitives internally
- Replace `@remixicon/react` imports with `i-ri-*` icon classes
- Fix `cloneElement` in `app-operations` to merge className via `cn()`
  instead of replacing it (broke CSS-class icons)
- Replace invalid `overflow-wrap-anywhere` with `wrap-anywhere`
- Delete `base/content-dialog/` (component, stories, tests)
- Prune orphaned eslint-suppressions

Made-with: Cursor
This commit is contained in:
yyh 2026-04-14 14:30:52 +08:00
parent 0d3ada2bc9
commit 1049cbaa19
No known key found for this signature in database
8 changed files with 128 additions and 334 deletions

View File

@ -11,22 +11,26 @@ vi.mock('../../../base/app-icon', () => ({
),
}))
vi.mock('@/app/components/base/content-dialog', () => ({
default: ({ show, onClose, children, className }: {
show: boolean
onClose: () => void
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ open, onOpenChange, children }: {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
className?: string
}) => (
show
open
? (
<div data-testid="content-dialog" className={className}>
<button type="button" data-testid="dialog-close" onClick={onClose}>Close</button>
<div data-testid="detail-drawer">
<button type="button" data-testid="dialog-close" onClick={() => onOpenChange(false)}>Close</button>
{children}
</div>
)
: null
),
DialogPortal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DialogBackdrop: () => <div data-testid="dialog-backdrop" />,
DialogPopup: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="dialog-popup" className={className}>{children}</div>
),
}))
vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view', () => ({
@ -96,12 +100,12 @@ describe('AppInfoDetailPanel', () => {
describe('Rendering', () => {
it('should not render when show is false', () => {
render(<AppInfoDetailPanel {...defaultProps} show={false} />)
expect(screen.queryByTestId('content-dialog')).not.toBeInTheDocument()
expect(screen.queryByTestId('detail-drawer')).not.toBeInTheDocument()
})
it('should render dialog when show is true', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
expect(screen.getByTestId('content-dialog')).toBeInTheDocument()
expect(screen.getByTestId('detail-drawer')).toBeInTheDocument()
})
it('should display app name', () => {

View File

@ -1,20 +1,17 @@
import type { Operation } from './app-operations'
import type { AppInfoModalType } from './use-app-info-actions'
import type { App, AppSSO } from '@/types/app'
import {
RiDeleteBinLine,
RiEditLine,
RiExchange2Line,
RiFileCopy2Line,
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import * as React from 'react'
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import {
Dialog,
DialogBackdrop,
DialogPopup,
DialogPortal,
} from '@/app/components/base/ui/dialog'
import { AppModeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
@ -37,23 +34,28 @@ const AppInfoDetailPanel = ({
}: AppInfoDetailPanelProps) => {
const { t } = useTranslation()
const handleOpenChange = useCallback((open: boolean) => {
if (!open)
onClose()
}, [onClose])
const primaryOperations = useMemo<Operation[]>(() => [
{
id: 'edit',
title: t('editApp', { ns: 'app' }),
icon: <RiEditLine />,
icon: <span className="i-ri-edit-line size-4" />,
onClick: () => openModal('edit'),
},
{
id: 'duplicate',
title: t('duplicate', { ns: 'app' }),
icon: <RiFileCopy2Line />,
icon: <span className="i-ri-file-copy-2-line size-4" />,
onClick: () => openModal('duplicate'),
},
{
id: 'export',
title: t('export', { ns: 'app' }),
icon: <RiFileDownloadLine />,
icon: <span className="i-ri-file-download-line size-4" />,
onClick: exportCheck,
},
], [t, openModal, exportCheck])
@ -63,7 +65,7 @@ const AppInfoDetailPanel = ({
? [{
id: 'import',
title: t('common.importDSL', { ns: 'workflow' }),
icon: <RiFileUploadLine />,
icon: <span className="i-ri-file-upload-line size-4" />,
onClick: () => openModal('importDSL'),
}]
: [],
@ -77,7 +79,7 @@ const AppInfoDetailPanel = ({
{
id: 'delete',
title: t('operation.delete', { ns: 'common' }),
icon: <RiDeleteBinLine />,
icon: <span className="i-ri-delete-bin-line size-4" />,
onClick: () => openModal('delete'),
},
], [appDetail.mode, t, openModal])
@ -88,63 +90,64 @@ const AppInfoDetailPanel = ({
return {
id: 'switch',
title: t('switch', { ns: 'app' }),
icon: <RiExchange2Line />,
icon: <span className="i-ri-exchange-2-line size-4" />,
onClick: () => openModal('switch'),
}
}, [appDetail.mode, t, openModal])
return (
<ContentDialog
show={show}
onClose={onClose}
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl p-0!"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
<AppIcon
size="large"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">
{getAppModeLabel(appDetail.mode, t)}
<Dialog open={show} onOpenChange={handleOpenChange}>
<DialogPortal>
<DialogBackdrop className="duration-300" />
<DialogPopup className="inset-y-0 left-0 m-2 flex w-[420px] flex-col rounded-2xl border-r border-divider-burn bg-app-detail-bg transition-transform duration-300 ease-out data-ending-style:-translate-x-[calc(100%+theme(spacing.2))] data-starting-style:-translate-x-[calc(100%+theme(spacing.2))] motion-reduce:transition-none">
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
<AppIcon
size="large"
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<div className="max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-anywhere text-text-tertiary">
{appDetail.description}
</div>
)}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
secondaryOperations={secondaryOperations}
/>
</div>
</div>
{appDetail.description && (
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal wrap-break-word text-text-tertiary system-xs-regular">
{appDetail.description}
</div>
)}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
size="medium"
variant="ghost"
className="gap-0.5"
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
</Button>
</div>
)}
</ContentDialog>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
size="medium"
variant="ghost"
className="gap-0.5"
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
</Button>
</div>
)}
</DialogPopup>
</DialogPortal>
</Dialog>
)
}

View File

@ -1,8 +1,8 @@
import type { JSX } from 'react'
import { RiMoreLine } from '@remixicon/react'
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
export type Operation = {
@ -133,8 +133,8 @@ const AppOperations = ({
className="gap-px"
tabIndex={-1}
>
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
<span className="text-components-button-secondary-text system-xs-medium">
{cloneElement(operation.icon, { className: cn(operation.icon.props.className, 'h-3.5 w-3.5 text-components-button-secondary-text') })}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
@ -146,8 +146,8 @@ const AppOperations = ({
className="gap-px"
tabIndex={-1}
>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="text-components-button-secondary-text system-xs-medium">
<span className="i-ri-more-line h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('operation.more', { ns: 'common' })}
</span>
</Button>
@ -162,8 +162,8 @@ const AppOperations = ({
className="gap-px"
onClick={operation.onClick}
>
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
<span className="text-components-button-secondary-text system-xs-medium">
{cloneElement(operation.icon, { className: cn(operation.icon.props.className, 'h-3.5 w-3.5 text-components-button-secondary-text') })}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
@ -181,8 +181,8 @@ const AppOperations = ({
variant="secondary"
className="gap-px"
>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="text-components-button-secondary-text system-xs-medium">
<span className="i-ri-more-line h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('operation.more', { ns: 'common' })}
</span>
</Button>
@ -199,8 +199,8 @@ const AppOperations = ({
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="text-text-secondary system-md-regular">{item.title}</span>
{cloneElement(item.icon, { className: cn(item.icon.props.className, 'h-4 w-4 text-text-tertiary') })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</div>
))}
</div>

View File

@ -1,59 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ContentDialog from '../index'
describe('ContentDialog', () => {
it('renders children when show is true', async () => {
render(
<ContentDialog show={true}>
<div>Dialog body</div>
</ContentDialog>,
)
await screen.findByText('Dialog body')
expect(screen.getByText('Dialog body')).toBeInTheDocument()
const backdrop = document.querySelector('.bg-app-detail-overlay-bg')
expect(backdrop).toBeTruthy()
})
it('does not render children when show is false', () => {
render(
<ContentDialog show={false}>
<div>Hidden content</div>
</ContentDialog>,
)
expect(screen.queryByText('Hidden content')).toBeNull()
expect(document.querySelector('.bg-app-detail-overlay-bg')).toBeNull()
})
it('calls onClose when backdrop is clicked', async () => {
const onClose = vi.fn()
render(
<ContentDialog show={true} onClose={onClose}>
<div>Body</div>
</ContentDialog>,
)
const user = userEvent.setup()
const backdrop = document.querySelector('.bg-app-detail-overlay-bg') as HTMLElement | null
expect(backdrop).toBeTruthy()
await user.click(backdrop!)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('applies provided className to the content panel', () => {
render(
<ContentDialog show={true} className="my-panel-class">
<div>Panel content</div>
</ContentDialog>,
)
const contentPanel = document.querySelector('.bg-app-detail-bg') as HTMLElement | null
expect(contentPanel).toBeTruthy()
expect(contentPanel?.className).toContain('my-panel-class')
expect(screen.getByText('Panel content')).toBeInTheDocument()
})
})

View File

@ -1,119 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useEffect, useState } from 'react'
import ContentDialog from '.'
type Props = React.ComponentProps<typeof ContentDialog>
const meta = {
title: 'Base/Feedback/ContentDialog',
component: ContentDialog,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Sliding panel overlay used in the app detail view. Includes dimmed backdrop and animated entrance/exit transitions.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional classes applied to the sliding panel container.',
},
show: {
control: 'boolean',
description: 'Controls visibility of the dialog.',
},
onClose: {
control: false,
description: 'Invoked when the overlay/backdrop is clicked.',
},
children: {
control: false,
table: { disable: true },
},
},
args: {
show: false,
children: null,
},
} satisfies Meta<typeof ContentDialog>
export default meta
type Story = StoryObj<typeof meta>
const DemoWrapper = (props: Props) => {
const [open, setOpen] = useState(props.show)
useEffect(() => {
setOpen(props.show)
}, [props.show])
return (
<div className="relative h-[480px] w-full overflow-hidden bg-gray-100">
<div className="flex h-full items-center justify-center">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open dialog
</button>
</div>
<ContentDialog
{...props}
show={open}
onClose={() => {
props.onClose?.()
setOpen(false)
}}
>
<div className="flex h-full flex-col space-y-4 bg-white p-6">
<h2 className="text-lg font-semibold text-gray-900">Plan summary</h2>
<p className="text-sm text-gray-600">
Use this area to present rich content for the selected run, configuration details, or
any supporting context.
</p>
<div className="flex-1 overflow-y-auto rounded-md border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
Scrollable placeholder content. Add domain-specific information, activity logs, or
editors in the real application.
</div>
<div className="flex justify-end gap-2 pt-4">
<button
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setOpen(false)}
>
Cancel
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Apply changes
</button>
</div>
</div>
</ContentDialog>
</div>
)
}
export const Default: Story = {
args: {
children: null,
},
render: args => <DemoWrapper {...args} />,
}
export const NarrowPanel: Story = {
render: args => <DemoWrapper {...args} />,
args: {
className: 'max-w-[420px]',
children: null,
},
parameters: {
docs: {
description: {
story: 'Applies a custom width class to show the dialog as a narrower information panel.',
},
},
},
}

View File

@ -1,40 +0,0 @@
import type { ReactNode } from 'react'
import { Transition, TransitionChild } from '@headlessui/react'
import { cn } from '@/utils/classnames'
type ContentDialogProps = {
className?: string
show: boolean
onClose?: () => void
children: ReactNode
}
const ContentDialog = ({
className,
show,
onClose,
children,
}: ContentDialogProps) => {
return (
<Transition
show={show}
as="div"
className="absolute left-0 top-0 z-[70] box-border h-full w-full p-2"
>
<TransitionChild>
<div
className={cn('absolute inset-0 left-0 w-full bg-app-detail-overlay-bg', 'duration-300 ease-in data-closed:opacity-0', 'data-enter:opacity-100', 'data-leave:opacity-0')}
onClick={onClose}
/>
</TransitionChild>
<TransitionChild>
<div className={cn('absolute left-0 w-full border-r border-divider-burn bg-app-detail-bg', 'duration-100 ease-in data-closed:-translate-x-full', 'data-enter:translate-x-0 data-enter:duration-300 data-enter:ease-out', 'data-leave:-translate-x-full data-leave:duration-200 data-leave:ease-in', className)}>
{children}
</div>
</TransitionChild>
</Transition>
)
}
export default ContentDialog

View File

@ -42,6 +42,37 @@ export function DialogCloseButton({
)
}
export function DialogBackdrop({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseDialog.Backdrop>) {
return (
<BaseDialog.Backdrop
{...props}
className={cn(
'fixed inset-0 z-1002 bg-background-overlay',
'transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}
/>
)
}
export function DialogPopup({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>) {
return (
<BaseDialog.Popup
{...props}
className={cn('fixed z-1002', className)}
>
{children}
</BaseDialog.Popup>
)
}
type DialogContentProps = {
children: React.ReactNode
className?: string
@ -57,24 +88,16 @@ export function DialogContent({
}: DialogContentProps) {
return (
<DialogPortal>
<BaseDialog.Backdrop
{...backdropProps}
<DialogBackdrop className={overlayClassName} {...backdropProps} />
<DialogPopup
className={cn(
'inset-0 fixed z-1002 bg-background-overlay',
'transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none',
overlayClassName,
backdropProps?.className,
)}
/>
<BaseDialog.Popup
className={cn(
'fixed top-1/2 left-1/2 z-1002 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'top-1/2 left-1/2 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto overscroll-contain rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'transition-[transform,scale,opacity] duration-150 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 motion-reduce:transition-none',
className,
)}
>
{children}
</BaseDialog.Popup>
</DialogPopup>
</DialogPortal>
)
}

View File

@ -314,11 +314,6 @@
"count": 1
}
},
"app/components/app-sidebar/app-info/app-info-detail-panel.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 5
}
},
"app/components/app-sidebar/app-info/app-info-trigger.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
@ -330,9 +325,6 @@
},
"react/set-state-in-effect": {
"count": 4
},
"tailwindcss/enforce-consistent-class-order": {
"count": 5
}
},
"app/components/app-sidebar/app-sidebar-dropdown.tsx": {
@ -1996,16 +1988,6 @@
"count": 6
}
},
"app/components/base/content-dialog/index.stories.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"app/components/base/content-dialog/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/copy-feedback/index.tsx": {
"no-restricted-imports": {
"count": 1