mirror of
https://github.com/langgenius/dify.git
synced 2026-03-05 15:47:06 +08:00
refactor: migrate workflow onboarding modal to base dialog (#32915)
This commit is contained in:
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user