refactor: migrate workflow onboarding modal to base dialog (#32915)

This commit is contained in:
yyh
2026-03-04 11:13:43 +08:00
committed by GitHub
parent 3398962bfa
commit b68ee600c1
10 changed files with 239 additions and 437 deletions

View File

@ -1,11 +1,13 @@
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
Dialog,
DialogClose,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '../index'
@ -29,7 +31,7 @@ describe('Dialog wrapper', () => {
})
describe('Props', () => {
it('should not render close button when closable is omitted', () => {
it('should not render close button when DialogCloseButton is not provided', () => {
render(
<Dialog open>
<DialogContent>
@ -41,20 +43,47 @@ describe('Dialog wrapper', () => {
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('should render close button when closable is true', () => {
it('should render explicit close button with custom aria-label', () => {
render(
<Dialog open>
<DialogContent closable>
<DialogContent>
<DialogCloseButton aria-label="Dismiss dialog" />
<span>Dialog body</span>
</DialogContent>
</Dialog>,
)
const dialog = screen.getByRole('dialog')
const closeButton = screen.getByRole('button', { name: 'Close' })
expect(screen.getByRole('button', { name: 'Dismiss dialog' })).toBeInTheDocument()
})
expect(dialog).toContainElement(closeButton)
expect(closeButton).toHaveAttribute('aria-label', 'Close')
it('should render default close button label when aria-label is omitted', () => {
render(
<Dialog open>
<DialogContent>
<DialogCloseButton />
<span>Dialog body</span>
</DialogContent>
</Dialog>,
)
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
it('should forward close button props to base primitive', () => {
const onClick = vi.fn()
render(
<Dialog open>
<DialogContent>
<DialogCloseButton data-testid="close-button" disabled onClick={onClick} />
<span>Dialog body</span>
</DialogContent>
</Dialog>,
)
const closeButton = screen.getByTestId('close-button')
expect(closeButton).toBeDisabled()
fireEvent.click(closeButton)
expect(onClick).not.toHaveBeenCalled()
})
})
@ -65,6 +94,7 @@ describe('Dialog wrapper', () => {
expect(DialogTitle).toBe(BaseDialog.Title)
expect(DialogDescription).toBe(BaseDialog.Description)
expect(DialogClose).toBe(BaseDialog.Close)
expect(DialogPortal).toBe(BaseDialog.Portal)
})
})
})

View File

@ -16,22 +16,42 @@ export const DialogTrigger = BaseDialog.Trigger
export const DialogTitle = BaseDialog.Title
export const DialogDescription = BaseDialog.Description
export const DialogClose = BaseDialog.Close
export const DialogPortal = BaseDialog.Portal
type DialogCloseButtonProps = Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Close>, 'children'>
export function DialogCloseButton({
className,
'aria-label': ariaLabel = 'Close',
...props
}: DialogCloseButtonProps) {
return (
<BaseDialog.Close
aria-label={ariaLabel}
{...props}
className={cn(
'absolute right-6 top-6 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-2xl hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
>
<span aria-hidden="true" className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</BaseDialog.Close>
)
}
type DialogContentProps = {
children: React.ReactNode
className?: string
overlayClassName?: string
closable?: boolean
}
export function DialogContent({
children,
className,
overlayClassName,
closable = false,
}: DialogContentProps) {
return (
<BaseDialog.Portal>
<DialogPortal>
<BaseDialog.Backdrop
className={cn(
'fixed inset-0 z-50 bg-background-overlay',
@ -41,18 +61,13 @@ export function DialogContent({
/>
<BaseDialog.Popup
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
'fixed left-1/2 top-1/2 z-50 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-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
className,
)}
>
{closable && (
<BaseDialog.Close aria-label="Close" className="absolute right-6 top-6 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-2xl hover:bg-state-base-hover">
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</BaseDialog.Close>
)}
{children}
</BaseDialog.Popup>
</BaseDialog.Portal>
</DialogPortal>
)
}

View File

@ -147,7 +147,6 @@ const WorkflowChildren = () => {
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
autoGenerateWebhookUrl(newNode.id)
console.log('Node successfully saved to draft')
},
onError: () => {
console.error('Failed to save node to draft')

View File

@ -1,65 +1,32 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { BlockEnum } from '@/app/components/workflow/types'
import WorkflowOnboardingModal from '../index'
// Mock Modal component
vi.mock('@/app/components/base/modal', () => ({
default: function MockModal({
isShow,
onClose,
children,
closable,
vi.mock('@/app/components/workflow/block-selector', () => ({
default: function MockNodeSelector({
open,
onSelect,
trigger,
}: {
isShow: boolean
onClose?: () => void
children?: React.ReactNode
closable?: boolean
open?: boolean
onSelect: (type: BlockEnum, config?: Record<string, unknown>) => void
trigger?: ((open: boolean) => ReactNode) | ReactNode
}) {
if (!isShow)
return null
return (
<div data-testid="modal" role="dialog">
{closable && (
<button data-testid="modal-close-button" onClick={onClose}>
Close
</button>
<div data-testid="mock-node-selector">
{typeof trigger === 'function' ? trigger(Boolean(open)) : trigger}
{open && (
<div>
<button data-testid="select-trigger-schedule" onClick={() => onSelect(BlockEnum.TriggerSchedule)}>
Select Trigger Schedule
</button>
<button data-testid="select-trigger-webhook" onClick={() => onSelect(BlockEnum.TriggerWebhook, { config: 'test' })}>
Select Trigger Webhook
</button>
</div>
)}
{children}
</div>
)
},
}))
// Mock StartNodeSelectionPanel (using real component would be better for integration,
// but for this test we'll mock to control behavior)
vi.mock('../start-node-selection-panel', () => ({
default: function MockStartNodeSelectionPanel({
onSelectUserInput,
onSelectTrigger,
}: {
onSelectUserInput?: () => void
onSelectTrigger?: (type: BlockEnum, config?: Record<string, unknown>) => void
}) {
return (
<div data-testid="start-node-selection-panel">
<button data-testid="select-user-input" onClick={onSelectUserInput}>
Select User Input
</button>
<button
data-testid="select-trigger-schedule"
onClick={() => onSelectTrigger?.(BlockEnum.TriggerSchedule)}
>
Select Trigger Schedule
</button>
<button
data-testid="select-trigger-webhook"
onClick={() => onSelectTrigger?.(BlockEnum.TriggerWebhook, { config: 'test' })}
>
Select Trigger Webhook
</button>
</div>
)
},
@ -79,401 +46,292 @@ describe('WorkflowOnboardingModal', () => {
vi.clearAllMocks()
})
// Helper function to render component
const renderComponent = (props = {}) => {
return render(<WorkflowOnboardingModal {...defaultProps} {...props} />)
}
const getBackdrop = () => document.body.querySelector('.bg-workflow-canvas-canvas-overlay')
const getUserInputHeading = () => screen.getByRole('heading', { name: 'workflow.onboarding.userInputFull' })
const getTriggerHeading = () => screen.getByRole('heading', { name: 'workflow.onboarding.trigger' })
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
renderComponent()
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render modal when isShow is true', () => {
// Arrange & Act
it('should render dialog when isShow is true', () => {
renderComponent({ isShow: true })
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should not render modal when isShow is false', () => {
// Arrange & Act
it('should not render dialog when isShow is false', () => {
renderComponent({ isShow: false })
// Assert
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render modal title', () => {
// Arrange & Act
it('should render title', () => {
renderComponent()
// Assert
expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument()
})
it('should render modal description', () => {
// Arrange & Act
const { container } = renderComponent()
it('should render description', () => {
renderComponent()
// Assert - Check both parts of description (separated by link)
const descriptionDiv = container.querySelector('.body-xs-regular.leading-4')
expect(descriptionDiv).toBeInTheDocument()
expect(descriptionDiv).toHaveTextContent('workflow.onboarding.description')
expect(screen.getByText('workflow.onboarding.description')).toBeInTheDocument()
})
it('should render StartNodeSelectionPanel', () => {
// Arrange & Act
renderComponent()
// Assert
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
expect(getUserInputHeading()).toBeInTheDocument()
expect(getTriggerHeading()).toBeInTheDocument()
})
it('should render ESC tip when modal is shown', () => {
// Arrange & Act
it('should render ESC tip when shown', () => {
renderComponent({ isShow: true })
// Assert
expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument()
})
it('should not render ESC tip when modal is hidden', () => {
// Arrange & Act
it('should not render ESC tip when hidden', () => {
renderComponent({ isShow: false })
// Assert
expect(screen.queryByText('workflow.onboarding.escTip.press')).not.toBeInTheDocument()
})
it('should have correct styling for title', () => {
// Arrange & Act
renderComponent()
// Assert
const title = screen.getByText('workflow.onboarding.title')
expect(title).toHaveClass('title-2xl-semi-bold')
expect(title).toHaveClass('text-text-primary')
})
it('should have modal close button', () => {
// Arrange & Act
it('should have close button', () => {
renderComponent()
// Assert
expect(screen.getByTestId('modal-close-button')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
})
it('should render workflow canvas backdrop when shown', () => {
renderComponent({ isShow: true })
const backdrop = getBackdrop()
expect(backdrop).toBeInTheDocument()
expect(backdrop).not.toHaveClass('opacity-20')
})
})
// Props tests (REQUIRED)
describe('Props', () => {
it('should accept isShow prop', () => {
// Arrange & Act
const { rerender } = renderComponent({ isShow: false })
// Assert
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// Act
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should accept onClose prop', () => {
// Arrange
const customOnClose = vi.fn()
// Act
renderComponent({ onClose: customOnClose })
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should accept onSelectStartNode prop', () => {
// Arrange
const customHandler = vi.fn()
// Act
renderComponent({ onSelectStartNode: customHandler })
// Assert
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
})
it('should handle undefined onClose gracefully', () => {
// Arrange & Act
expect(() => {
renderComponent({ onClose: undefined })
}).not.toThrow()
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
it('should handle undefined onSelectStartNode gracefully', () => {
// Arrange & Act
expect(() => {
renderComponent({ onSelectStartNode: undefined })
}).not.toThrow()
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(getUserInputHeading()).toBeInTheDocument()
})
})
// User Interactions - Start Node Selection
describe('User Interactions - Start Node Selection', () => {
it('should call onSelectStartNode with Start block when user input is selected', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const userInputButton = screen.getByTestId('select-user-input')
await user.click(userInputButton)
await user.click(getUserInputHeading())
// Assert
expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1)
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start)
})
it('should call onClose after selecting user input', async () => {
// Arrange
it('should not call onClose when selecting user input (parent handles closing)', async () => {
const user = userEvent.setup()
renderComponent()
// Act
const userInputButton = screen.getByTestId('select-user-input')
await user.click(userInputButton)
await user.click(getUserInputHeading())
// Assert
expect(mockOnClose).toHaveBeenCalledTimes(1)
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should call onSelectStartNode with trigger type when trigger is selected', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const triggerButton = screen.getByTestId('select-trigger-schedule')
await user.click(triggerButton)
await user.click(getTriggerHeading())
await user.click(screen.getByTestId('select-trigger-schedule'))
// Assert
expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1)
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined)
})
it('should call onClose after selecting trigger', async () => {
// Arrange
it('should not call onClose when selecting trigger (parent handles closing)', async () => {
const user = userEvent.setup()
renderComponent()
// Act
const triggerButton = screen.getByTestId('select-trigger-schedule')
await user.click(triggerButton)
await user.click(getTriggerHeading())
await user.click(screen.getByTestId('select-trigger-schedule'))
// Assert
expect(mockOnClose).toHaveBeenCalledTimes(1)
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should pass tool config when selecting trigger with config', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const webhookButton = screen.getByTestId('select-trigger-webhook')
await user.click(webhookButton)
await user.click(getTriggerHeading())
await user.click(screen.getByTestId('select-trigger-webhook'))
// Assert
expect(mockOnSelectStartNode).toHaveBeenCalledTimes(1)
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' })
expect(mockOnClose).toHaveBeenCalledTimes(1)
expect(mockOnClose).not.toHaveBeenCalled()
})
})
// User Interactions - Modal Close
describe('User Interactions - Modal Close', () => {
describe('User Interactions - Dialog Close', () => {
it('should call onClose when close button is clicked', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const closeButton = screen.getByTestId('modal-close-button')
await user.click(closeButton)
await user.click(screen.getByRole('button', { name: 'Close' }))
// Assert
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should not call onSelectStartNode when closing without selection', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
const closeButton = screen.getByTestId('modal-close-button')
await user.click(closeButton)
await user.click(screen.getByRole('button', { name: 'Close' }))
// Assert
expect(mockOnSelectStartNode).not.toHaveBeenCalled()
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onClose exactly once when close button is clicked (no double-close)', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
renderComponent({ onClose })
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when clicking backdrop', async () => {
const user = userEvent.setup()
renderComponent()
const backdrop = getBackdrop()
expect(backdrop).toBeInTheDocument()
if (!backdrop)
throw new Error('backdrop should exist when dialog is open')
await user.click(backdrop)
expect(mockOnClose).not.toHaveBeenCalled()
})
})
// Keyboard Event Handling
describe('Keyboard Event Handling', () => {
it('should call onClose when ESC key is pressed', () => {
// Arrange
renderComponent({ isShow: true })
// Act
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
// Assert
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should not call onClose when other keys are pressed', () => {
// Arrange
renderComponent({ isShow: true })
// Act
fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' })
fireEvent.keyDown(document, { key: 'Tab', code: 'Tab' })
fireEvent.keyDown(document, { key: 'a', code: 'KeyA' })
// Assert
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should not call onClose when ESC is pressed but modal is hidden', () => {
// Arrange
it('should not call onClose when ESC is pressed but dialog is hidden', () => {
renderComponent({ isShow: false })
// Act
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should clean up event listener on unmount', () => {
// Arrange
it('should clean up on unmount', () => {
const { unmount } = renderComponent({ isShow: true })
// Act
unmount()
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should update event listener when isShow changes', () => {
// Arrange
it('should respond to ESC based on open state', () => {
const { rerender } = renderComponent({ isShow: true })
// Act - Press ESC when shown
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
// Assert
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(mockOnClose).toHaveBeenCalledTimes(1)
// Act - Hide modal and clear mock
mockOnClose.mockClear()
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={false} />)
// Act - Press ESC when hidden
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
// Assert
fireEvent.keyDown(document, { key: 'Escape' })
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should handle multiple ESC key presses', () => {
// Arrange
renderComponent({ isShow: true })
// Act
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
// Assert
expect(mockOnClose).toHaveBeenCalledTimes(3)
})
})
// Edge Cases (REQUIRED)
describe('Edge Cases', () => {
it('should handle rapid modal show/hide toggling', async () => {
// Arrange
it('should handle rapid show/hide toggling', async () => {
const { rerender } = renderComponent({ isShow: false })
// Assert
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// Act
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
// Act
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={false} />)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
it('should handle selecting multiple nodes in sequence', async () => {
// Arrange
const user = userEvent.setup()
const { rerender } = renderComponent()
// Act - Select user input
await user.click(screen.getByTestId('select-user-input'))
// Assert
await user.click(getUserInputHeading())
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start)
expect(mockOnClose).toHaveBeenCalledTimes(1)
expect(mockOnClose).not.toHaveBeenCalled()
// Act - Re-show modal and select trigger
mockOnClose.mockClear()
mockOnSelectStartNode.mockClear()
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} />)
await user.click(getTriggerHeading())
await user.click(screen.getByTestId('select-trigger-schedule'))
// Assert
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerSchedule, undefined)
expect(mockOnClose).toHaveBeenCalledTimes(1)
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should handle prop updates correctly', () => {
// Arrange
const { rerender } = renderComponent({ isShow: true })
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Act - Update props
const newOnClose = vi.fn()
const newOnSelectStartNode = vi.fn()
rerender(
@ -484,169 +342,120 @@ describe('WorkflowOnboardingModal', () => {
/>,
)
// Assert - Modal still renders with new props
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should handle onClose being called multiple times', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act
await user.click(screen.getByTestId('modal-close-button'))
await user.click(screen.getByTestId('modal-close-button'))
// Assert
expect(mockOnClose).toHaveBeenCalledTimes(2)
})
it('should maintain modal state when props change', () => {
// Arrange
it('should maintain dialog when props change', () => {
const { rerender } = renderComponent({ isShow: true })
// Assert
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Act - Change onClose handler
const newOnClose = vi.fn()
rerender(<WorkflowOnboardingModal {...defaultProps} isShow={true} onClose={newOnClose} />)
// Assert - Modal should still be visible
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})
// Accessibility Tests
describe('Accessibility', () => {
it('should have dialog role', () => {
// Arrange & Act
renderComponent()
// Assert
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should have proper heading hierarchy', () => {
// Arrange & Act
const { container } = renderComponent()
renderComponent()
// Assert
const heading = container.querySelector('h3')
const heading = screen.getByRole('heading', { name: 'workflow.onboarding.title' })
expect(heading).toBeInTheDocument()
expect(heading).toHaveTextContent('workflow.onboarding.title')
})
it('should have keyboard navigation support via ESC key', () => {
// Arrange
it('should expose dialog accessible name from title', () => {
renderComponent()
expect(screen.getByRole('dialog', { name: 'workflow.onboarding.title' })).toBeInTheDocument()
})
it('should support ESC key dismissal', () => {
renderComponent({ isShow: true })
// Act
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
// Assert
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should have visible ESC key hint', () => {
// Arrange & Act
renderComponent({ isShow: true })
// Assert - ShortcutsName component renders keys in div elements with system-kbd class
const escKey = screen.getByText('workflow.onboarding.escTip.key')
// ShortcutsName renders a <div> with class system-kbd, not a <kbd> element
expect(escKey.closest('.system-kbd')).toBeInTheDocument()
})
it('should have descriptive text for ESC functionality', () => {
// Arrange & Act
renderComponent({ isShow: true })
// Assert
expect(screen.getByText('workflow.onboarding.escTip.press')).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.toDismiss')).toBeInTheDocument()
})
it('should have proper text color classes', () => {
// Arrange & Act
renderComponent()
// Assert
const title = screen.getByText('workflow.onboarding.title')
expect(title).toHaveClass('text-text-primary')
})
})
// Integration Tests
describe('Integration', () => {
it('should complete full flow of selecting user input node', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Assert - Initial state
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument()
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
expect(getUserInputHeading()).toBeInTheDocument()
// Act - Select user input
await user.click(screen.getByTestId('select-user-input'))
await user.click(getUserInputHeading())
// Assert - Callbacks called
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.Start)
expect(mockOnClose).toHaveBeenCalledTimes(1)
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should complete full flow of selecting trigger node', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Assert - Initial state
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Act - Select trigger
await user.click(getTriggerHeading())
await user.click(screen.getByTestId('select-trigger-webhook'))
// Assert - Callbacks called with config
expect(mockOnSelectStartNode).toHaveBeenCalledWith(BlockEnum.TriggerWebhook, { config: 'test' })
expect(mockOnClose).toHaveBeenCalledTimes(1)
expect(mockOnClose).not.toHaveBeenCalled()
})
it('should render all components in correct hierarchy', () => {
// Arrange & Act
const { container } = renderComponent()
renderComponent()
// Assert - Modal is the root
expect(screen.getByTestId('modal')).toBeInTheDocument()
// Assert - Header elements
const heading = container.querySelector('h3')
expect(heading).toBeInTheDocument()
// Assert - Selection panel
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()
// Assert - ESC tip
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.title')).toBeInTheDocument()
expect(getUserInputHeading()).toBeInTheDocument()
expect(screen.getByText('workflow.onboarding.escTip.key')).toBeInTheDocument()
expect(dialog).not.toContainElement(screen.getByText('workflow.onboarding.escTip.key'))
})
it('should coordinate between keyboard and click interactions', async () => {
// Arrange
const user = userEvent.setup()
renderComponent()
// Act - Click close button
await user.click(screen.getByTestId('modal-close-button'))
// Assert
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(mockOnClose).toHaveBeenCalledTimes(1)
// Act - Clear and try ESC key
mockOnClose.mockClear()
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
// Assert
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -47,7 +47,6 @@ describe('StartNodeOption', () => {
// Assert
const title = screen.getByText('Test Title')
expect(title).toBeInTheDocument()
expect(title).toHaveClass('system-md-semi-bold')
expect(title).toHaveClass('text-text-primary')
})

View File

@ -1,12 +1,8 @@
'use client'
import type { FC } from 'react'
import type { PluginDefaultValue } from '@/app/components/workflow/block-selector/types'
import {
useCallback,
useEffect,
} from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { BlockEnum } from '@/app/components/workflow/types'
import StartNodeSelectionPanel from './start-node-selection-panel'
@ -24,63 +20,39 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
}) => {
const { t } = useTranslation()
const handleSelectUserInput = useCallback(() => {
onSelectStartNode(BlockEnum.Start)
onClose() // Close modal after selection
}, [onSelectStartNode, onClose])
const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
onSelectStartNode(nodeType, toolConfig)
onClose() // Close modal after selection
}, [onSelectStartNode, onClose])
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isShow)
onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isShow, onClose])
return (
<>
<Modal
isShow={isShow}
onClose={onClose}
<Dialog open={isShow} onOpenChange={onClose} disablePointerDismissal>
<DialogContent
className="w-[618px] max-w-[618px] rounded-2xl border border-effects-highlight bg-background-default-subtle shadow-lg"
overlayOpacity
closable
clickOutsideNotClose
overlayClassName="bg-workflow-canvas-canvas-overlay"
>
<DialogCloseButton />
<div className="pb-4">
{/* Header */}
<div className="mb-6">
<h3 className="title-2xl-semi-bold mb-2 text-text-primary">
<DialogTitle className="mb-2 text-text-primary title-2xl-semi-bold">
{t('onboarding.title', { ns: 'workflow' })}
</h3>
<div className="body-xs-regular leading-4 text-text-tertiary">
</DialogTitle>
<DialogDescription className="leading-4 text-text-tertiary body-xs-regular">
{t('onboarding.description', { ns: 'workflow' })}
</div>
</DialogDescription>
</div>
{/* Content */}
<StartNodeSelectionPanel
onSelectUserInput={handleSelectUserInput}
onSelectTrigger={handleTriggerSelect}
onSelectUserInput={() => onSelectStartNode(BlockEnum.Start)}
onSelectTrigger={onSelectStartNode}
/>
</div>
</Modal>
</DialogContent>
{/* ESC tip below modal */}
{isShow && (
<div className="body-xs-regular pointer-events-none fixed left-1/2 top-1/2 z-[70] flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary">
<DialogPortal>
<div className="pointer-events-none fixed left-1/2 top-1/2 z-50 flex -translate-x-1/2 translate-y-[165px] items-center gap-1 text-text-quaternary body-xs-regular">
<span>{t('onboarding.escTip.press', { ns: 'workflow' })}</span>
<ShortcutsName keys={[t('onboarding.escTip.key', { ns: 'workflow' })]} textColor="secondary" />
<span>{t('onboarding.escTip.toDismiss', { ns: 'workflow' })}</span>
</div>
)}
</>
</DialogPortal>
</Dialog>
)
}

View File

@ -1,6 +1,5 @@
'use client'
import type { FC, ReactNode } from 'react'
import { cn } from '@/utils/classnames'
type StartNodeOptionProps = {
icon: ReactNode
@ -20,22 +19,18 @@ const StartNodeOption: FC<StartNodeOptionProps> = ({
return (
<div
onClick={onClick}
className={cn(
'hover:border-components-panel-border-active flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md',
)}
className="flex h-40 w-[280px] cursor-pointer flex-col gap-2 rounded-xl border-[0.5px] border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-4 shadow-sm transition-all hover:shadow-md"
>
{/* Icon */}
<div className="shrink-0">
{icon}
</div>
{/* Text content */}
<div className="flex h-[74px] flex-col gap-1 py-0.5">
<div className="h-5 leading-5">
<h3 className="system-md-semi-bold text-text-primary">
<h3 className="text-text-primary">
{title}
{subtitle && (
<span className="system-md-regular text-text-quaternary">
<span className="text-text-quaternary system-md-regular">
{' '}
{subtitle}
</span>
@ -44,7 +39,7 @@ const StartNodeOption: FC<StartNodeOptionProps> = ({
</div>
<div className="h-12 leading-4">
<p className="system-xs-regular text-text-tertiary">
<p className="text-text-tertiary system-xs-regular">
{description}
</p>
</div>

View File

@ -21,10 +21,6 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
const { t } = useTranslation()
const [showTriggerSelector, setShowTriggerSelector] = useState(false)
const handleTriggerClick = useCallback(() => {
setShowTriggerSelector(true)
}, [])
const handleTriggerSelect = useCallback((nodeType: BlockEnum, toolConfig?: PluginDefaultValue) => {
setShowTriggerSelector(false)
onSelectTrigger(nodeType, toolConfig)
@ -67,10 +63,9 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
)}
title={t('onboarding.trigger', { ns: 'workflow' })}
description={t('onboarding.triggerDescription', { ns: 'workflow' })}
onClick={handleTriggerClick}
onClick={() => setShowTriggerSelector(true)}
/>
)}
popupClassName="z-[1200]"
/>
</div>
)

View File

@ -1,10 +1,14 @@
# Overlay Migration Guide
This document tracks the migration away from legacy `portal-to-follow-elem` APIs.
This document tracks the migration away from legacy overlay APIs.
## Scope
- Deprecated API: `@/app/components/base/portal-to-follow-elem`
- Deprecated imports:
- `@/app/components/base/portal-to-follow-elem`
- `@/app/components/base/tooltip`
- `@/app/components/base/modal`
- `@/app/components/base/select` (including `custom` / `pure`)
- Replacement primitives:
- `@/app/components/base/ui/tooltip`
- `@/app/components/base/ui/dropdown-menu`
@ -15,33 +19,33 @@ This document tracks the migration away from legacy `portal-to-follow-elem` APIs
## ESLint policy
- `no-restricted-imports` blocks new usage of `portal-to-follow-elem`.
- The rule is enabled for normal source files and test files are excluded.
- Legacy `app/components/base/*` callers are temporarily allowlisted in ESLint config.
- `no-restricted-imports` blocks all deprecated imports listed above.
- The rule is enabled for normal source files (`.ts` / `.tsx`) and test files are excluded.
- Legacy `app/components/base/*` callers are temporarily allowlisted in `OVERLAY_MIGRATION_LEGACY_BASE_FILES` (`web/eslint.constants.mjs`).
- New files must not be added to the allowlist without migration owner approval.
## Migration phases
1. Business/UI features outside `app/components/base/**`
- Migrate old calls to semantic primitives.
- Keep `eslint-suppressions.json` stable or shrinking.
- Migrate old calls to semantic primitives from `@/app/components/base/ui/**`.
- Keep deprecated imports out of newly touched files.
1. Legacy base components in allowlist
- Migrate allowlisted base callers gradually.
- Remove migrated files from allowlist immediately.
- Remove migrated files from `OVERLAY_MIGRATION_LEGACY_BASE_FILES` immediately.
1. Cleanup
- Remove remaining suppressions for `no-restricted-imports`.
- Remove legacy `portal-to-follow-elem` implementation.
- Remove remaining allowlist entries.
- Remove legacy overlay implementations when import count reaches zero.
## Suppression maintenance
## Allowlist maintenance
- After each migration batch, run:
```sh
pnpm eslint --prune-suppressions --pass-on-unpruned-suppressions <changed-files>
pnpm -C web lint:fix --prune-suppressions <changed-files>
```
- Never increase suppressions to bypass new code.
- Prefer direct migration over adding suppression entries.
- If a migrated file was in the allowlist, remove it from `web/eslint.constants.mjs` in the same PR.
- Never increase allowlist scope to bypass new code.
## React Refresh policy for base UI primitives

View File

@ -6298,9 +6298,6 @@
}
},
"app/components/workflow-app/components/workflow-children.tsx": {
"no-console": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@ -6310,19 +6307,6 @@
"count": 2
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/workflow-app/components/workflow-onboarding-modal/start-node-option.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/workflow-app/hooks/use-DSL.ts": {
"ts/no-explicit-any": {
"count": 1