refactor(web): migrate pipeline DSL import form (#41232)

This commit is contained in:
yyh
2026-08-25 09:41:36 +00:00
committed by GitHub
parent 9ce92d8605
commit eb7a1829fe
6 changed files with 110 additions and 282 deletions
-19
View File
@@ -1847,14 +1847,6 @@
"count": 3
}
},
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts": {
"erasable-syntax-only/enums": {
"count": 1
@@ -1863,17 +1855,6 @@
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/item.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/list/create-card.tsx": {
@@ -1,10 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DSLConfirmModal from '../dsl-confirm-modal'
import Header from '../header'
import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index'
import Tab from '../tab'
import TabItem from '../tab/item'
import Uploader from '../uploader'
const mockPush = vi.fn()
@@ -124,8 +122,15 @@ describe('CreateFromDSLModal', () => {
it('should render file tab by default', () => {
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />, { wrapper: createWrapper() })
expect(screen.getByText('app.importFromDSLFile'))!.toBeInTheDocument()
expect(screen.getByText('app.importFromDSLUrl'))!.toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'app.importFromDSL' }))!.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'app.importFromDSLFile' }))!.toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByRole('tab', { name: 'app.importFromDSLUrl' }))!.toHaveAttribute(
'aria-selected',
'false',
)
})
it('should render cancel and import buttons', () => {
@@ -158,8 +163,7 @@ describe('CreateFromDSLModal', () => {
{ wrapper: createWrapper() },
)
expect(screen.getByText('DSL URL'))!.toBeInTheDocument()
expect(screen.getByPlaceholderText('app.importFromDSLUrlPlaceholder'))!.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'DSL URL' }))!.toBeInTheDocument()
})
})
@@ -210,18 +214,17 @@ describe('CreateFromDSLModal', () => {
})
describe('State Management', () => {
it('should switch between tabs', () => {
it('should move focus from the URL tab directly into its field', async () => {
const user = userEvent.setup()
render(<CreateFromDSLModal show={true} onClose={vi.fn()} />, { wrapper: createWrapper() })
// Initially file tab is active
// Initially file tab is active
expect(screen.getByText('app.dslUploader.button'))!.toBeInTheDocument()
const urlTab = screen.getByRole('tab', { name: 'app.importFromDSLUrl' })
await user.click(urlTab)
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
const input = screen.getByRole('textbox', { name: 'DSL URL' })
await user.tab()
// URL input should be visible
// URL input should be visible
expect(screen.getByText('DSL URL'))!.toBeInTheDocument()
expect(input)!.toHaveFocus()
})
it('should update URL value when typing', () => {
@@ -513,13 +516,32 @@ describe('CreateFromDSLModal', () => {
const onClose = vi.fn()
render(<CreateFromDSLModal show={true} onClose={onClose} />, { wrapper: createWrapper() })
// Find and click the close icon in header
const closeIcon = document.querySelector('[class*="cursor-pointer"]')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
if (closeIcon) {
fireEvent.click(closeIcon)
expect(onClose).toHaveBeenCalled()
}
expect(onClose).toHaveBeenCalled()
})
it('should submit a URL import when Enter is pressed in the URL field', async () => {
const user = userEvent.setup()
mockImportDSL.mockResolvedValue(createImportDSLResponse())
render(
<CreateFromDSLModal
show={true}
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
/>,
{ wrapper: createWrapper() },
)
const input = screen.getByRole('textbox', { name: 'DSL URL' })
await user.type(input, 'https://example.com/test.pipeline{Enter}')
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalledWith({
mode: 'yaml-url',
yaml_url: 'https://example.com/test.pipeline',
})
})
})
it('should close modal on ESC key press', () => {
@@ -1141,131 +1163,6 @@ describe('CreateFromDSLModal', () => {
})
})
// Header Component Tests
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render title', () => {
render(<Header onClose={vi.fn()} />)
expect(screen.getByText('app.importFromDSL'))!.toBeInTheDocument()
})
it('should render close icon', () => {
render(<Header onClose={vi.fn()} />)
// Check for close icon container
const closeButton = document.querySelector('[class*="cursor-pointer"]')
expect(closeButton)!.toBeInTheDocument()
})
})
describe('Event Handlers', () => {
it('should call onClose when close icon is clicked', () => {
const onClose = vi.fn()
render(<Header onClose={onClose} />)
const closeButton = document.querySelector('[class*="cursor-pointer"]')!
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalled()
})
})
})
// Tab Component Tests
describe('Tab', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render both tabs', () => {
render(<Tab currentTab={CreateFromDSLModalTab.FROM_FILE} setCurrentTab={vi.fn()} />)
expect(screen.getByText('app.importFromDSLFile'))!.toBeInTheDocument()
expect(screen.getByText('app.importFromDSLUrl'))!.toBeInTheDocument()
})
})
describe('Event Handlers', () => {
it('should call setCurrentTab when clicking file tab', () => {
const setCurrentTab = vi.fn()
render(<Tab currentTab={CreateFromDSLModalTab.FROM_URL} setCurrentTab={setCurrentTab} />)
fireEvent.click(screen.getByText('app.importFromDSLFile'))
// Tab uses bind() which passes the key as first argument and event as second
expect(setCurrentTab).toHaveBeenCalled()
expect(setCurrentTab.mock.calls[0]![0]).toBe(CreateFromDSLModalTab.FROM_FILE)
})
it('should call setCurrentTab when clicking URL tab', () => {
const setCurrentTab = vi.fn()
render(<Tab currentTab={CreateFromDSLModalTab.FROM_FILE} setCurrentTab={setCurrentTab} />)
fireEvent.click(screen.getByText('app.importFromDSLUrl'))
// Tab uses bind() which passes the key as first argument and event as second
expect(setCurrentTab).toHaveBeenCalled()
expect(setCurrentTab.mock.calls[0]![0]).toBe(CreateFromDSLModalTab.FROM_URL)
})
})
})
// Tab Item Component Tests
describe('TabItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render label', () => {
render(<TabItem isActive={false} label="Test Tab" onClick={vi.fn()} />)
expect(screen.getByText('Test Tab'))!.toBeInTheDocument()
})
it('should render active indicator when active', () => {
render(<TabItem isActive={true} label="Test Tab" onClick={vi.fn()} />)
// Active indicator is the bottom border div
const indicator = document.querySelector('[class*="bg-util-colors-blue"]')
expect(indicator)!.toBeInTheDocument()
})
it('should not render active indicator when inactive', () => {
render(<TabItem isActive={false} label="Test Tab" onClick={vi.fn()} />)
const indicator = document.querySelector('[class*="bg-util-colors-blue"]')
expect(indicator).toBeNull()
})
it('should have active text color when active', () => {
render(<TabItem isActive={true} label="Test Tab" onClick={vi.fn()} />)
const item = screen.getByText('Test Tab')
expect(item.className).toContain('text-text-primary')
})
it('should have inactive text color when inactive', () => {
render(<TabItem isActive={false} label="Test Tab" onClick={vi.fn()} />)
const item = screen.getByText('Test Tab')
expect(item.className).toContain('text-text-tertiary')
})
})
describe('Event Handlers', () => {
it('should call onClick when clicked', () => {
const onClick = vi.fn()
render(<TabItem isActive={false} label="Test Tab" onClick={onClick} />)
fireEvent.click(screen.getByText('Test Tab'))
expect(onClick).toHaveBeenCalled()
})
})
})
// Uploader Component Tests
describe('Uploader', () => {
beforeEach(() => {
@@ -1,25 +0,0 @@
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type HeaderProps = {
onClose: () => void
}
const Header = ({ onClose }: HeaderProps) => {
const { t } = useTranslation()
return (
<div className="relative flex items-center justify-between pt-6 pr-14 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
{t(($) => $.importFromDSL, { ns: 'app' })}
<div
className="absolute top-5 right-5 flex size-8 cursor-pointer items-center"
onClick={onClose}
>
<RiCloseLine className="size-4.5 text-text-tertiary" />
</div>
</div>
)
}
export default React.memo(Header)
@@ -1,12 +1,13 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Input } from '@langgenius/dify-ui/input'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import DSLConfirmModal from './dsl-confirm-modal'
import Header from './header'
import { CreateFromDSLModalTab, useDSLImport } from './hooks/use-dsl-import'
import Tab from './tab'
import Uploader from './uploader'
export { CreateFromDSLModalTab }
@@ -53,29 +54,69 @@ const CreateFromDSLModal = ({
<>
<Dialog open={show} onOpenChange={(open) => !open && !showConfirmModal && onClose()}>
<DialogContent className="w-full max-w-120! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
<Header onClose={onClose} />
<Tab currentTab={currentTab} setCurrentTab={setCurrentTab} />
<div className="px-6 py-4">
{currentTab === CreateFromDSLModalTab.FROM_FILE && (
<Uploader className="mt-0" file={currentFile} updateFile={handleFile} />
)}
{currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="leading6 mb-1 system-md-semibold text-text-secondary">DSL URL</div>
<Input
placeholder={t(($) => $.importFromDSLUrlPlaceholder, { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={(e) => setDslUrlValue(e.target.value)}
/>
</div>
)}
</div>
<div className="flex justify-end gap-x-2 p-6 pt-5">
<Button onClick={onClose}>{t(($) => $['newApp.Cancel'], { ns: 'app' })}</Button>
<Button disabled={buttonDisabled} variant="primary" onClick={handleCreateApp}>
<span>{t(($) => $['newApp.import'], { ns: 'app' })}</span>
</Button>
<div className="relative flex items-center justify-between pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $.importFromDSL, { ns: 'app' })}
</DialogTitle>
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="absolute top-5 right-5"
size="lg"
onClick={onClose}
>
<span aria-hidden="true" className="i-ri-close-line size-4.5" />
</IconButton>
</div>
<form
onSubmit={(event) => {
event.preventDefault()
handleCreateApp()
}}
>
<Tabs
value={currentTab}
onValueChange={(value) => {
if (value !== null) setCurrentTab(value as CreateFromDSLModalTab)
}}
>
<TabsList className="h-9 gap-6 border-b border-divider-subtle px-6">
<TabsTab value={CreateFromDSLModalTab.FROM_FILE} className="h-full py-0">
{t(($) => $.importFromDSLFile, { ns: 'app' })}
</TabsTab>
<TabsTab value={CreateFromDSLModalTab.FROM_URL} className="h-full py-0">
{t(($) => $.importFromDSLUrl, { ns: 'app' })}
</TabsTab>
</TabsList>
<TabsPanel
value={CreateFromDSLModalTab.FROM_FILE}
tabIndex={-1}
className="px-6 py-4"
>
<Uploader className="mt-0" file={currentFile} updateFile={handleFile} />
</TabsPanel>
<TabsPanel value={CreateFromDSLModalTab.FROM_URL} tabIndex={-1} className="px-6 py-4">
<Field name="dslUrl">
<FieldLabel className="w-full py-0 text-sm leading-5 font-semibold">
DSL URL
</FieldLabel>
<Input
autoComplete="off"
placeholder={t(($) => $.importFromDSLUrlPlaceholder, { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={(e) => setDslUrlValue(e.target.value)}
/>
</Field>
</TabsPanel>
</Tabs>
<div className="flex justify-end gap-2 p-6 pt-5">
<Button type="button" onClick={onClose}>
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
</Button>
<Button type="submit" disabled={buttonDisabled} variant="primary">
{t(($) => $['newApp.import'], { ns: 'app' })}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{showConfirmModal && (
@@ -1,39 +0,0 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { CreateFromDSLModalTab } from '../hooks/use-dsl-import'
import Item from './item'
type TabProps = {
currentTab: CreateFromDSLModalTab
setCurrentTab: (tab: CreateFromDSLModalTab) => void
}
const Tab = ({ currentTab, setCurrentTab }: TabProps) => {
const { t } = useTranslation()
const tabs = [
{
key: CreateFromDSLModalTab.FROM_FILE,
label: t(($) => $.importFromDSLFile, { ns: 'app' }),
},
{
key: CreateFromDSLModalTab.FROM_URL,
label: t(($) => $.importFromDSLUrl, { ns: 'app' }),
},
]
return (
<div className="flex h-9 items-center gap-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
{tabs.map((tab) => (
<Item
key={tab.key}
isActive={currentTab === tab.key}
label={tab.label}
onClick={setCurrentTab.bind(null, tab.key)}
/>
))}
</div>
)
}
export default Tab
@@ -1,27 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
type ItemProps = {
isActive: boolean
label: string
onClick: () => void
}
const Item = ({ isActive, label, onClick }: ItemProps) => {
return (
<div
className={cn(
'relative flex h-full cursor-pointer items-center system-md-semibold text-text-tertiary',
isActive && 'text-text-primary',
)}
onClick={onClick}
>
{label}
{isActive && (
<div className="absolute bottom-0 h-0.5 w-full bg-util-colors-blue-brand-blue-brand-600" />
)}
</div>
)
}
export default React.memo(Item)