fix(web): improve import DSL dialog states (#39432)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-07-23 10:26:59 +08:00 committed by GitHub
parent bd8f0104d6
commit 6736f4005b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 663 additions and 634 deletions

View File

@ -663,11 +663,6 @@
"count": 1
}
},
"web/app/components/app/create-from-dsl-modal/index.tsx": {
"eslint-react/set-state-in-effect": {
"count": 1
}
},
"web/app/components/app/duplicate-modal/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -1432,7 +1427,7 @@
},
"web/app/components/base/icons/src/public/files/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 11
"count": 10
}
},
"web/app/components/base/icons/src/public/knowledge/dataset-card/index.ts": {

View File

@ -1,5 +1,6 @@
/* oxlint-disable typescript/no-explicit-any */
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage'
import { DSLImportMode, DSLImportStatus } from '@/models/app'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
@ -62,10 +63,40 @@ vi.mock('@/utils/create-app-tracking', () => ({
trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
}))
vi.mock('@/service/apps', () => ({
importDSL: (...args: unknown[]) => mockImportDSL(...args),
importDSLConfirm: (...args: unknown[]) => mockImportDSLConfirm(...args),
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
return {
...actual,
consoleClient: {
...actual.consoleClient,
apps: {
...actual.consoleClient.apps,
imports: {
post: ({ body }: { body: Record<string, unknown> }) => mockImportDSL(body),
},
},
},
consoleQuery: {
...actual.consoleQuery,
systemFeatures: actual.consoleQuery.systemFeatures,
apps: {
...actual.consoleQuery.apps,
imports: {
byImportId: {
confirm: {
post: {
mutationOptions: () => ({
mutationFn: ({ params }: { params: { import_id: string } }) =>
mockImportDSLConfirm({ import_id: params.import_id }),
}),
},
},
},
},
},
},
}
})
vi.mock('@/service/use-apps', () => ({
useInvalidateAppList: () => mockInvalidateAppList,
}))
@ -135,20 +166,10 @@ describe('CreateFromDSLModal', () => {
mockWorkspacePermissionKeys = ['app.create_and_management']
localStorage.clear()
class MockFileReader {
result = 'app: demo'
onload: ((event: { target: { result: string } }) => void) | null = null
readAsText() {
this.onload?.({ target: { result: this.result } })
}
}
// @ts-expect-error test-only file reader shim
globalThis.FileReader = MockFileReader
})
afterEach(() => {
vi.useRealTimers()
Object.defineProperty(File.prototype, 'text', {
configurable: true,
value: vi.fn().mockResolvedValue('app: demo'),
})
})
const getCreateButton = () => screen.getByRole('button', { name: /newApp\.Create/i })
@ -190,6 +211,55 @@ describe('CreateFromDSLModal', () => {
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should expose the URL as a named form field', () => {
render(<CreateFromDSLModal show onClose={vi.fn()} activeTab={CreateFromDSLModalTab.FROM_URL} />)
const urlInput = screen.getByRole('textbox', {
name: /(?:^|\.)importFromDSLUrl(?=$|:)/,
})
expect(urlInput).toHaveAttribute('name', 'dslUrl')
expect(urlInput).toHaveAttribute('type', 'url')
expect(urlInput.closest('form')).toBeInTheDocument()
})
it('should initially focus Browse when the file import dialog opens', async () => {
render(<CreateFromDSLModal show onClose={vi.fn()} />)
await waitFor(() => {
expect(
screen.getByRole('button', { name: /(?:^|\.)dslUploader\.browse(?=$|:)/ }),
).toHaveFocus()
})
})
it('should move focus from each active tab directly to its first panel control', async () => {
const user = userEvent.setup()
render(<CreateFromDSLModal show onClose={vi.fn()} />)
const fileTab = screen.getByRole('tab', {
name: /(?:^|\.)importFromDSLFile(?=$|:)/,
})
const browseButton = screen.getByRole('button', {
name: /(?:^|\.)dslUploader\.browse(?=$|:)/,
})
await user.click(fileTab)
await user.tab()
expect(browseButton).toHaveFocus()
const urlTab = screen.getByRole('tab', {
name: /(?:^|\.)importFromDSLUrl(?=$|:)/,
})
await user.click(urlTab)
const urlInput = screen.getByRole('textbox', {
name: /(?:^|\.)importFromDSLUrl(?=$|:)/,
})
await user.tab()
expect(urlInput).toHaveFocus()
})
it('should render the import shortcut with kbd primitives', () => {
render(
<CreateFromDSLModal
@ -367,7 +437,6 @@ describe('CreateFromDSLModal', () => {
})
it('should show the DSL mismatch modal and confirm a pending import', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'import-3',
status: DSLImportStatus.PENDING,
@ -395,10 +464,6 @@ describe('CreateFromDSLModal', () => {
fireEvent.click(getCreateButton())
})
await act(async () => {
vi.advanceTimersByTime(300)
})
expect(
screen.getAllByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/)[0],
)!.toBeInTheDocument()
@ -428,7 +493,6 @@ describe('CreateFromDSLModal', () => {
})
it('should surface Agent warnings after confirming a pending import', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'agent-import-pending',
status: DSLImportStatus.PENDING,
@ -467,9 +531,6 @@ describe('CreateFromDSLModal', () => {
await act(async () => {
fireEvent.click(getCreateButton())
})
await act(async () => {
vi.advanceTimersByTime(300)
})
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: /newApp\.Confirm/ })[0]!)
})
@ -495,7 +556,6 @@ describe('CreateFromDSLModal', () => {
})
it('should close the DSL mismatch modal when dialog requests close', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'import-close',
status: DSLImportStatus.PENDING,
@ -516,13 +576,8 @@ describe('CreateFromDSLModal', () => {
fireEvent.click(getCreateButton())
})
await act(async () => {
vi.advanceTimersByTime(300)
})
expect(screen.getByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/))!.toBeInTheDocument()
vi.useRealTimers()
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
await waitFor(() => {
@ -533,7 +588,6 @@ describe('CreateFromDSLModal', () => {
})
it('should close the DSL mismatch modal when cancel is clicked', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'import-cancel',
status: DSLImportStatus.PENDING,
@ -554,13 +608,8 @@ describe('CreateFromDSLModal', () => {
fireEvent.click(getCreateButton())
})
await act(async () => {
vi.advanceTimersByTime(300)
})
expect(screen.getByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/))!.toBeInTheDocument()
vi.useRealTimers()
fireEvent.click(
screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Cancel(?=$|:)/ }).at(-1)!,
)
@ -572,7 +621,7 @@ describe('CreateFromDSLModal', () => {
})
})
it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => {
it('should show import progress and lock dialog interactions while a request is in flight', async () => {
let resolveImport!: (value: {
id: string
status: DSLImportStatus
@ -586,20 +635,32 @@ describe('CreateFromDSLModal', () => {
resolveImport = resolve as typeof resolveImport
}),
)
const handleClose = vi.fn()
render(
<CreateFromDSLModal
show
onClose={vi.fn()}
onClose={handleClose}
activeTab={CreateFromDSLModalTab.FROM_URL}
dslUrl="https://example.com/app.yml"
/>,
)
fireEvent.click(getCreateButton())
await waitFor(() => {
expect(mockImportDSL).toHaveBeenCalledTimes(1)
expect(getCreateButton()).toHaveAttribute('aria-disabled', 'true')
})
fireEvent.click(getCreateButton())
expect(mockImportDSL).toHaveBeenCalledTimes(1)
expect(screen.getByText(/(?:^|\.)importFromDSLFile(?=$|:)/).closest('button')).toHaveAttribute(
'aria-disabled',
'true',
)
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Cancel(?=$|:)/ })[0]!)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
expect(handleClose).not.toHaveBeenCalled()
await act(async () => {
resolveImport({
@ -610,15 +671,68 @@ describe('CreateFromDSLModal', () => {
permission_keys: ['app.acl.view_layout'],
})
})
})
mockImportDSL.mockResolvedValueOnce(undefined)
it('should show confirmation progress and prevent cancellation while confirming', async () => {
let resolveConfirm!: (value: {
id: string
status: DSLImportStatus
app_id: string
app_mode: string
}) => void
mockImportDSL.mockResolvedValue({
id: 'import-confirming',
status: DSLImportStatus.PENDING,
imported_dsl_version: '1.0.0',
current_dsl_version: '2.0.0',
})
mockImportDSLConfirm.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveConfirm = resolve as typeof resolveConfirm
}),
)
render(
<CreateFromDSLModal
show
onClose={vi.fn()}
activeTab={CreateFromDSLModalTab.FROM_URL}
dslUrl="https://example.com/app.yml"
/>,
)
await act(async () => {
fireEvent.click(getCreateButton())
})
expect(mockImportDSL).toHaveBeenCalledTimes(2)
expect(toastMocks.error).not.toHaveBeenCalled()
const confirmButton = screen.getByRole('button', {
name: /(?:^|\.)newApp\.Confirm(?=$|:)/,
})
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(confirmButton).toHaveAttribute('aria-disabled', 'true')
})
fireEvent.click(confirmButton)
expect(mockImportDSLConfirm).toHaveBeenCalledTimes(1)
const cancelButton = screen
.getAllByRole('button', { name: /(?:^|\.)newApp\.Cancel(?=$|:)/ })
.at(-1)!
expect(cancelButton).toBeDisabled()
fireEvent.click(cancelButton)
expect(screen.getByText(/(?:^|\.)newApp\.appCreateDSLErrorTitle(?=$|:)/)).toBeInTheDocument()
await act(async () => {
resolveConfirm({
id: 'import-confirming',
status: DSLImportStatus.COMPLETED,
app_id: 'app-confirming',
app_mode: AppModeEnum.WORKFLOW,
})
})
})
it('should handle keyboard shortcut and quota guard', async () => {
@ -706,7 +820,6 @@ describe('CreateFromDSLModal', () => {
})
it('should handle pending import confirmation failures and cancellation', async () => {
vi.useFakeTimers()
mockImportDSL.mockResolvedValue({
id: 'import-4',
status: DSLImportStatus.PENDING,
@ -731,7 +844,6 @@ describe('CreateFromDSLModal', () => {
await act(async () => {
fireEvent.click(getCreateButton())
vi.advanceTimersByTime(300)
})
fireEvent.click(
@ -743,7 +855,6 @@ describe('CreateFromDSLModal', () => {
await act(async () => {
fireEvent.click(getCreateButton())
vi.advanceTimersByTime(300)
})
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)newApp\.Confirm(?=$|:)/ })[0]!)

View File

@ -1,6 +1,8 @@
import { toast } from '@langgenius/dify-ui/toast'
import { fireEvent, render, screen } from '@testing-library/react'
import Uploader from '../uploader'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { Uploader } from '../uploader'
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
@ -16,7 +18,13 @@ describe('Uploader', () => {
const getDropZone = (container: HTMLElement) =>
(container.firstChild as HTMLElement).querySelector('div') as HTMLElement
const getHiddenInput = () => document.getElementById('fileUploader') as HTMLInputElement
const getHiddenInput = () => document.querySelector<HTMLInputElement>('input[type="file"]')!
function ControlledUploader({ initialFile }: { initialFile?: File }) {
const [file, setFile] = useState<File | undefined>(initialFile)
return <Uploader file={file} updateFile={setFile} />
}
it('should upload a single dropped file', () => {
const updateFile = vi.fn()
@ -94,6 +102,83 @@ describe('Uploader', () => {
expect(updateFile).toHaveBeenCalledWith(nextFile)
})
it('should move keyboard focus to the selected file row before the remove action', async () => {
const user = userEvent.setup()
const nextFile = new File(['next'], 'next.yml', { type: 'text/yaml' })
render(<ControlledUploader />)
const browseButton = screen.getByRole('button', {
name: /(?:^|\.)dslUploader\.browse(?=$|:)/,
})
browseButton.focus()
await user.keyboard('{Enter}')
fireEvent.change(getHiddenInput(), {
target: {
files: [nextFile],
},
})
const fileRow = screen.getByRole('group', { name: 'next.yml' })
const deleteButton = screen.getByRole('button', {
name: /(?:^|\.)operation\.delete(?=$|:)/,
})
expect(fileRow).toHaveFocus()
await user.tab()
expect(deleteButton).toHaveFocus()
})
it('should preserve focus ownership after selecting a file with a pointer', async () => {
const user = userEvent.setup()
const nextFile = new File(['next'], 'next.yml', { type: 'text/yaml' })
render(<ControlledUploader />)
await user.click(screen.getByRole('button', { name: /(?:^|\.)dslUploader\.browse(?=$|:)/ }))
fireEvent.change(getHiddenInput(), {
target: {
files: [nextFile],
},
})
expect(screen.getByRole('group', { name: 'next.yml' })).toHaveFocus()
})
it('should not move focus when a file is dropped', () => {
const nextFile = new File(['next'], 'next.yml', { type: 'text/yaml' })
const { container } = render(
<>
<button type="button">Outside</button>
<ControlledUploader />
</>,
)
const outsideButton = screen.getByRole('button', { name: 'Outside' })
const uploaderRoot = container.children[1] as HTMLElement
const dropZone = uploaderRoot.querySelector('input[type="file"]')?.nextElementSibling
outsideButton.focus()
fireEvent.drop(dropZone as Element, {
dataTransfer: {
files: [nextFile],
},
})
expect(outsideButton).toHaveFocus()
})
it('should restore keyboard focus to Browse after removing the file', async () => {
const user = userEvent.setup()
const file = new File(['name: demo'], 'demo.yml', { type: 'text/yaml' })
render(<ControlledUploader initialFile={file} />)
const deleteButton = screen.getByRole('button', {
name: /(?:^|\.)operation\.delete(?=$|:)/,
})
deleteButton.focus()
await user.keyboard('{Enter}')
expect(screen.getByRole('button', { name: /(?:^|\.)dslUploader\.browse(?=$|:)/ })).toHaveFocus()
})
it('should toggle drag styles and clear them when leaving the overlay', () => {
const updateFile = vi.fn()
const { container } = render(<Uploader file={undefined} updateFile={updateFile} />)

View File

@ -17,20 +17,23 @@ type DSLConfirmModalProps = {
onCancel: () => void
onConfirm: () => void
confirmDisabled?: boolean
confirmLoading?: boolean
}
const DSLConfirmModal = ({
function DSLConfirmModal({
versions = { importedVersion: '', systemVersion: '' },
onCancel,
onConfirm,
confirmDisabled = false,
}: DSLConfirmModalProps) => {
confirmLoading = false,
}: DSLConfirmModalProps) {
const { t } = useTranslation()
return (
<AlertDialog
open
onOpenChange={(open) => {
if (!open) onCancel()
if (!open && !confirmLoading) onCancel()
}}
>
<AlertDialogContent className="w-[480px] overflow-hidden! border-none text-left align-middle shadow-xl">
@ -56,10 +59,14 @@ const DSLConfirmModal = ({
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">
<AlertDialogCancelButton variant="secondary" disabled={confirmLoading}>
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={onConfirm} disabled={confirmDisabled}>
<AlertDialogConfirmButton
onClick={onConfirm}
disabled={confirmDisabled}
loading={confirmLoading}
>
{t(($) => $['newApp.Confirm'], { ns: 'app' })}
</AlertDialogConfirmButton>
</AlertDialogActions>

View File

@ -1,18 +1,25 @@
'use client'
import type { AppImportPayload, Import } from '@dify/contracts/api/console/apps/types.gen'
import type { Hotkey } from '@tanstack/react-hotkeys'
import type { MouseEventHandler } from 'react'
import type { AppModeEnum } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import {
Dialog,
DialogBackdrop,
DialogPopup,
DialogPortal,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Field, FieldControl, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
@ -21,16 +28,17 @@ import { userProfileIdAtom } from '@/context/account-state'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { DSLImportMode, DSLImportStatus } from '@/models/app'
import { useRouter } from '@/next/navigation'
import { importDSL, importDSLConfirm } from '@/service/apps'
import { consoleClient, consoleQuery } from '@/service/client'
import { useInvalidateAppList } from '@/service/use-apps'
import { AppModeEnum as AppMode } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { getDSLImportWarningDescription } from '@/utils/dsl-import-warning'
import { resolveImportedAppRedirectionTarget } from '@/utils/imported-app-redirection'
import DSLConfirmModal from './dsl-confirm-modal'
import { CreateFromDSLModalTab } from './types'
import Uploader from './uploader'
import { Uploader } from './uploader'
type CreateFromDSLModalProps = {
show: boolean
@ -41,350 +49,320 @@ type CreateFromDSLModalProps = {
droppedFile?: File
}
type ImportFormValues = {
dslUrl?: string
}
type PendingImport = {
id: string
importedVersion: string
systemVersion: string
}
type ImportSource =
| { type: (typeof CreateFromDSLModalTab)['FROM_FILE']; file: File }
| { type: (typeof CreateFromDSLModalTab)['FROM_URL']; url: string }
const CREATE_FROM_DSL_HOTKEY = 'Mod+Enter' satisfies Hotkey
const CreateFromDSLModal = ({
function getImportedAppMode(mode?: string | null): AppModeEnum | undefined {
switch (mode) {
case AppMode.COMPLETION:
return AppMode.COMPLETION
case AppMode.WORKFLOW:
return AppMode.WORKFLOW
case AppMode.CHAT:
return AppMode.CHAT
case AppMode.ADVANCED_CHAT:
return AppMode.ADVANCED_CHAT
case AppMode.AGENT_CHAT:
return AppMode.AGENT_CHAT
case AppMode.AGENT:
return AppMode.AGENT
default:
return undefined
}
}
function CreateFromDSLModal({
show,
onSuccess,
onClose,
activeTab = CreateFromDSLModalTab.FROM_FILE,
dslUrl = '',
droppedFile,
}: CreateFromDSLModalProps) => {
}: CreateFromDSLModalProps) {
const { push } = useRouter()
const { t } = useTranslation()
const formRef = useRef<HTMLFormElement>(null)
const browseButtonRef = useRef<HTMLButtonElement>(null)
const [currentFile, setCurrentFile] = useState<File | undefined>(droppedFile)
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState(activeTab)
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
const [showErrorModal, setShowErrorModal] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
const importMutation = useMutation({
mutationFn: async (source: ImportSource) => {
const body =
source.type === CreateFromDSLModalTab.FROM_FILE
? ({
mode: 'yaml-content',
yaml_content: await source.file.text(),
} satisfies AppImportPayload)
: ({
mode: 'yaml-url',
yaml_url: source.url,
} satisfies AppImportPayload)
return consoleClient.apps.imports.post({ body })
},
})
const confirmImportMutation = useMutation(
consoleQuery.apps.imports.byImportId.confirm.post.mutationOptions(),
)
const { handleCheckPluginDependencies } = usePluginDependencies()
const setNeedRefresh = useSetNeedRefreshAppList()
const invalidateAppList = useInvalidateAppList()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const readFile = useCallback((file: File) => {
const reader = new FileReader()
reader.onload = function (event) {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}, [])
const handleFile = useCallback(
(file?: File) => {
setCurrentFile(file)
if (file) readFile(file)
if (!file) setFileContent('')
},
[readFile],
)
const { plan, enableBilling } = useProviderContext()
const isAppsFull = enableBilling && plan.usage.buildApps >= plan.total.buildApps
const invalidateAppList = useInvalidateAppList()
const isImporting = importMutation.isPending
const isConfirming = confirmImportMutation.isPending
const isCreatingRef = useRef(false)
const handleCompletedImport = async (response: Import) => {
const appMode = getImportedAppMode(response.app_mode)
useEffect(() => {
if (droppedFile) readFile(droppedFile)
}, [droppedFile, readFile])
if (appMode) trackCreateApp({ source: 'studio_upload', appMode })
onSuccess?.()
onClose()
const onCreate = async (_e?: React.MouseEvent) => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue) return
if (isCreatingRef.current) return
isCreatingRef.current = true
try {
let response
toast(
t(($) => $[response.status === 'completed' ? 'newApp.appCreated' : 'newApp.caution'], {
ns: 'app',
}),
{
type: response.status === 'completed' ? 'success' : 'warning',
description:
response.status === 'completed-with-warnings'
? getDSLImportWarningDescription(response.warnings) ||
t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
: undefined,
},
)
setNeedRefresh('1')
invalidateAppList()
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
response = await importDSL({
mode: DSLImportMode.YAML_CONTENT,
yaml_content: fileContent || '',
})
}
if (currentTab === CreateFromDSLModalTab.FROM_URL) {
response = await importDSL({
mode: DSLImportMode.YAML_URL,
yaml_url: dslUrlValue || '',
})
}
if (!response.app_id || !appMode) return
if (!response) return
const {
id,
status,
app_id,
app_mode,
imported_dsl_version,
current_dsl_version,
permission_keys,
warnings,
} = response
if (
status === DSLImportStatus.COMPLETED ||
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
) {
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
if (onSuccess) onSuccess()
if (onClose) onClose()
toast(
t(
($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'],
{ ns: 'app' },
),
{
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
description:
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
? getDSLImportWarningDescription(warnings) ||
t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
: undefined,
},
)
setNeedRefresh('1')
invalidateAppList()
if (app_id) {
await handleCheckPluginDependencies(app_id)
const redirectionTarget = await resolveImportedAppRedirectionTarget({
id: app_id,
mode: app_mode,
permission_keys,
})
getRedirection(redirectionTarget, push, {
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,
isRbacEnabled,
})
}
} else if (status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: imported_dsl_version ?? '',
systemVersion: current_dsl_version ?? '',
})
setTimeout(() => {
setShowErrorModal(true)
}, 300)
setImportId(id)
} else {
toast.error(response.error || t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
} catch {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
isCreatingRef.current = false
await handleCheckPluginDependencies(response.app_id)
const redirectionTarget = await resolveImportedAppRedirectionTarget({
id: response.app_id,
mode: appMode,
permission_keys: response.permission_keys,
})
getRedirection(redirectionTarget, push, {
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,
isRbacEnabled: systemFeatures.rbac_enabled,
})
}
const { run: handleCreateApp } = useDebounceFn(onCreate, { wait: 300 })
const handleImportResponse = async (response: Import) => {
if (response.status === 'completed' || response.status === 'completed-with-warnings') {
await handleCompletedImport(response)
return
}
useHotkey(
CREATE_FROM_DSL_HOTKEY,
() => {
handleCreateApp(undefined)
},
{
enabled:
show &&
!isAppsFull &&
((currentTab === CreateFromDSLModalTab.FROM_FILE && !!currentFile) ||
(currentTab === CreateFromDSLModalTab.FROM_URL && !!dslUrlValue)),
ignoreInputs: false,
},
)
const onDSLConfirm: MouseEventHandler = async () => {
try {
if (!importId) return
const response = await importDSLConfirm({
import_id: importId,
if (response.status === 'pending') {
setPendingImport({
id: response.id,
importedVersion: response.imported_dsl_version ?? '',
systemVersion: response.current_dsl_version ?? '',
})
return
}
const { status, app_id, app_mode, permission_keys, warnings } = response
toast.error(response.error || t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
if (
status === DSLImportStatus.COMPLETED ||
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
) {
trackCreateApp({ source: 'studio_upload', appMode: app_mode })
if (onSuccess) onSuccess()
if (onClose) onClose()
const handleSubmit = async (values: ImportFormValues) => {
if (isAppsFull || isImporting) return
toast(
t(
($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'],
{ ns: 'app' },
),
{
type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
description:
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
? getDSLImportWarningDescription(warnings) ||
t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
: undefined,
},
)
if (app_id) await handleCheckPluginDependencies(app_id)
setNeedRefresh('1')
invalidateAppList()
if (app_id) {
const redirectionTarget = await resolveImportedAppRedirectionTarget({
id: app_id,
mode: app_mode,
permission_keys,
})
getRedirection(redirectionTarget, push, {
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,
isRbacEnabled,
})
}
} else if (status === DSLImportStatus.FAILED) {
toast.error(response.error || t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
try {
let source: ImportSource
if (currentTab === CreateFromDSLModalTab.FROM_FILE) {
if (!currentFile) return
source = { type: CreateFromDSLModalTab.FROM_FILE, file: currentFile }
} else {
const yamlUrl = values.dslUrl?.trim()
if (!yamlUrl) return
source = { type: CreateFromDSLModalTab.FROM_URL, url: yamlUrl }
}
const response = await importMutation.mutateAsync(source)
await handleImportResponse(response)
} catch {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
}
const tabs = [
{
key: CreateFromDSLModalTab.FROM_FILE,
label: t(($) => $.importFromDSLFile, { ns: 'app' }),
},
{
key: CreateFromDSLModalTab.FROM_URL,
label: t(($) => $.importFromDSLUrl, { ns: 'app' }),
},
]
const handleConfirm = async () => {
if (!pendingImport || isConfirming) return
const buttonDisabled = useMemo(() => {
if (isAppsFull) return true
if (currentTab === CreateFromDSLModalTab.FROM_FILE) return !currentFile
if (currentTab === CreateFromDSLModalTab.FROM_URL) return !dslUrlValue
return false
}, [isAppsFull, currentTab, currentFile, dslUrlValue])
try {
const response = await confirmImportMutation.mutateAsync({
params: { import_id: pendingImport.id },
})
if (response.status === 'completed' || response.status === 'completed-with-warnings') {
setPendingImport(null)
await handleCompletedImport(response)
return
}
if (response.status === 'failed')
toast.error(response.error || t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
} catch {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
}
const handleTabChange = (value: string | number) => {
if (value === CreateFromDSLModalTab.FROM_FILE) setCurrentTab(CreateFromDSLModalTab.FROM_FILE)
if (value === CreateFromDSLModalTab.FROM_URL) setCurrentTab(CreateFromDSLModalTab.FROM_URL)
}
const createDisabled =
isAppsFull || (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
useHotkey(CREATE_FROM_DSL_HOTKEY, () => formRef.current?.requestSubmit(), {
enabled: show && !createDisabled && !isImporting && !pendingImport,
ignoreInputs: false,
})
return (
<>
<Dialog open={show} onOpenChange={(open) => !open && !showErrorModal && onClose()}>
<DialogContent className="w-full max-w-[480px]! overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-xl">
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6 title-2xl-semi-bold text-text-primary">
{t(($) => $.importApp, { ns: 'app' })}
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
className="size-8 p-0"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line size-5 text-text-tertiary" />
</Button>
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 system-md-semibold text-text-tertiary">
{tabs.map((tab) => (
<button
type="button"
key={tab.key}
className={cn(
'relative flex h-full cursor-pointer items-center outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
currentTab === tab.key && 'text-text-primary',
)}
onClick={() => setCurrentTab(tab.key)}
>
{tab.label}
{currentTab === tab.key && (
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600"></div>
)}
</button>
))}
</div>
<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="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>
{isAppsFull && (
<div className="px-6">
<AppsFull className="mt-0" loc="app-create-dsl" />
</div>
)}
<div className="flex justify-end px-6 py-5">
<Button className="mr-2" onClick={onClose}>
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
</Button>
<Button
disabled={buttonDisabled}
variant="primary"
onClick={handleCreateApp}
className="gap-1"
>
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
<KbdGroup>
{CREATE_FROM_DSL_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</Kbd>
))}
</KbdGroup>
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={showErrorModal}
open={show}
onOpenChange={(open) => {
if (!open) setShowErrorModal(false)
if (!open && !isImporting && !pendingImport) onClose()
}}
>
<DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none text-left align-middle">
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">
{t(($) => $['newApp.appCreateDSLErrorTitle'], { ns: 'app' })}
<DialogPortal>
<DialogBackdrop />
<DialogPopup
initialFocus={browseButtonRef}
className="fixed top-1/2 left-1/2 max-h-[80dvh] w-120 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden overscroll-contain text-left align-middle"
>
<div className="flex items-center justify-between pt-6 pr-5 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $.importApp, { ns: 'app' })}
</DialogTitle>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
className="size-8 p-0"
disabled={isImporting}
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line size-5 text-text-tertiary" />
</Button>
</div>
<div className="flex grow flex-col system-md-regular text-text-secondary">
<div>{t(($) => $['newApp.appCreateDSLErrorPart1'], { ns: 'app' })}</div>
<div>{t(($) => $['newApp.appCreateDSLErrorPart2'], { ns: 'app' })}</div>
<br />
<div>
{t(($) => $['newApp.appCreateDSLErrorPart3'], { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
<Form<ImportFormValues> ref={formRef} onFormSubmit={handleSubmit}>
<Tabs value={currentTab} onValueChange={handleTabChange}>
<TabsList className="h-9 gap-6 border-b border-divider-subtle px-6">
<TabsTab
value={CreateFromDSLModalTab.FROM_FILE}
className="h-full pt-0 pb-0"
disabled={isImporting}
>
{t(($) => $.importFromDSLFile, { ns: 'app' })}
</TabsTab>
<TabsTab
value={CreateFromDSLModalTab.FROM_URL}
className="h-full pt-0 pb-0"
disabled={isImporting}
>
{t(($) => $.importFromDSLUrl, { ns: 'app' })}
</TabsTab>
</TabsList>
<TabsPanel
value={CreateFromDSLModalTab.FROM_FILE}
tabIndex={-1}
className="px-6 py-4"
>
<Uploader
browseButtonRef={browseButtonRef}
className="mt-0"
file={currentFile}
updateFile={setCurrentFile}
disabled={isImporting}
/>
</TabsPanel>
<TabsPanel
value={CreateFromDSLModalTab.FROM_URL}
tabIndex={-1}
className="px-6 py-4"
>
<Field name="dslUrl">
<FieldLabel>{t(($) => $.importFromDSLUrl, { ns: 'app' })}</FieldLabel>
<FieldControl
type="url"
inputMode="url"
autoComplete="off"
required
disabled={isImporting}
placeholder={t(($) => $.importFromDSLUrlPlaceholder, { ns: 'app' }) || ''}
defaultValue={dslUrl}
/>
<FieldError />
</Field>
</TabsPanel>
</Tabs>
{isAppsFull && (
<div className="px-6">
<AppsFull className="mt-0" loc="app-create-dsl" />
</div>
)}
<div className="flex justify-end px-6 py-5">
<Button className="mr-2" disabled={isImporting} onClick={onClose}>
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
</Button>
<Button
type="submit"
disabled={createDisabled}
loading={isImporting}
variant="primary"
className="gap-1"
>
<span>{t(($) => $['newApp.Create'], { ns: 'app' })}</span>
<KbdGroup>
{CREATE_FROM_DSL_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</Kbd>
))}
</KbdGroup>
</Button>
</div>
<div>
{t(($) => $['newApp.appCreateDSLErrorPart4'], { ns: 'app' })}
<span className="system-md-medium">{versions?.systemVersion}</span>
</div>
</div>
</div>
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
<Button variant="secondary" onClick={() => setShowErrorModal(false)}>
{t(($) => $['newApp.Cancel'], { ns: 'app' })}
</Button>
<Button variant="primary" tone="destructive" onClick={onDSLConfirm}>
{t(($) => $['newApp.Confirm'], { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Form>
</DialogPopup>
</DialogPortal>
</Dialog>
{pendingImport && (
<DSLConfirmModal
versions={{
importedVersion: pendingImport.importedVersion,
systemVersion: pendingImport.systemVersion,
}}
onCancel={() => {
if (!isConfirming) setPendingImport(null)
}}
onConfirm={handleConfirm}
confirmLoading={isConfirming}
/>
)}
</>
)
}

View File

@ -1,55 +1,82 @@
'use client'
import type { FC } from 'react'
import type { ChangeEvent, DragEvent, MouseEvent, RefObject } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiDeleteBinLine, RiUploadCloud2Line } from '@remixicon/react'
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useId, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files'
import { formatFileSize } from '@/utils/format'
type Props = Readonly<{
file: File | undefined
updateFile: (file?: File) => void
browseButtonRef?: RefObject<HTMLButtonElement | null>
className?: string
accept?: string
displayName?: string
disabled?: boolean
}>
const Uploader: FC<Props> = ({
export function Uploader({
file,
updateFile,
browseButtonRef,
className,
accept = '.yaml,.yml',
displayName = 'YAML',
}) => {
disabled = false,
}: Props) {
const { t } = useTranslation()
const [dragging, setDragging] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<HTMLDivElement>(null)
const fileUploader = useRef<HTMLInputElement>(null)
const fileUploaderRef = useRef<HTMLInputElement>(null)
const internalBrowseButtonRef = useRef<HTMLButtonElement>(null)
const resolvedBrowseButtonRef = browseButtonRef ?? internalBrowseButtonRef
const fileRowRef = useRef<HTMLDivElement>(null)
const removeButtonRef = useRef<HTMLButtonElement>(null)
const fileNameId = useId()
const fileMetadataId = useId()
const pendingFocusRef = useRef<{
target: 'browse' | 'file'
focusVisible: boolean
} | null>(null)
const handleDragEnter = (e: DragEvent) => {
useLayoutEffect(() => {
const pendingFocus = pendingFocusRef.current
if (!pendingFocus) return
const focusTarget = file
? pendingFocus.target === 'file' && fileRowRef.current
: pendingFocus.target === 'browse' && resolvedBrowseButtonRef.current
if (!focusTarget) return
focusTarget.focus({
preventScroll: true,
...(pendingFocus.focusVisible && { focusVisible: true }),
})
pendingFocusRef.current = null
}, [file, resolvedBrowseButtonRef])
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
if (disabled) return
if (e.target !== dragRef.current) setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
if (e.target === dragRef.current) setDragging(false)
}
const handleDrop = (e: DragEvent) => {
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setDragging(false)
if (!e.dataTransfer) return
if (disabled || !e.dataTransfer) return
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
toast.error(t(($) => $['stepOne.uploader.validation.count'], { ns: 'datasetCreation' }))
@ -57,48 +84,54 @@ const Uploader: FC<Props> = ({
}
updateFile(files[0])
}
const selectHandle = () => {
const selectHandle = (e: MouseEvent<HTMLButtonElement>) => {
if (disabled) return
pendingFocusRef.current =
document.activeElement === resolvedBrowseButtonRef.current
? { target: 'file', focusVisible: e.detail === 0 }
: null
const originalFile = file
if (fileUploader.current) {
fileUploader.current.value = ''
fileUploader.current.click()
// If no file is selected, restore the original file
fileUploader.current.oncancel = () => updateFile(originalFile)
if (fileUploaderRef.current) {
fileUploaderRef.current.value = ''
fileUploaderRef.current.click()
fileUploaderRef.current.oncancel = () => {
pendingFocusRef.current = null
updateFile(originalFile)
}
}
}
const removeFile = () => {
if (fileUploader.current) fileUploader.current.value = ''
const removeFile = (e: MouseEvent<HTMLButtonElement>) => {
if (disabled) return
pendingFocusRef.current =
document.activeElement === removeButtonRef.current
? { target: 'browse', focusVisible: e.detail === 0 }
: null
if (fileUploaderRef.current) fileUploaderRef.current.value = ''
updateFile()
}
const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
const fileChangeHandle = (e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const currentFile = e.target.files?.[0]
if (!currentFile) pendingFocusRef.current = null
updateFile(currentFile)
}
useEffect(() => {
dropRef.current?.addEventListener('dragenter', handleDragEnter)
dropRef.current?.addEventListener('dragover', handleDragOver)
dropRef.current?.addEventListener('dragleave', handleDragLeave)
dropRef.current?.addEventListener('drop', handleDrop)
return () => {
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
dropRef.current?.removeEventListener('dragover', handleDragOver)
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
dropRef.current?.removeEventListener('drop', handleDrop)
}
}, [])
return (
<div className={cn('mt-6', className)}>
<input
ref={fileUploader}
ref={fileUploaderRef}
style={{ display: 'none' }}
type="file"
id="fileUploader"
accept={accept}
disabled={disabled}
onChange={fileChangeHandle}
/>
<div ref={dropRef}>
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{!file && (
<div
className={cn(
@ -108,16 +141,19 @@ const Uploader: FC<Props> = ({
)}
>
<div className="flex w-full items-center justify-center space-x-2">
<RiUploadCloud2Line className="size-6 text-text-tertiary" />
<div className="text-text-tertiary">
{t(($) => $['dslUploader.button'], { ns: 'app' })}
<button
type="button"
className="inline cursor-pointer border-none bg-transparent p-0 pl-1 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
<span aria-hidden className="i-ri-upload-cloud-2-line size-6 text-text-tertiary" />
<div className="flex items-center text-text-tertiary">
<span>{t(($) => $['dslUploader.button'], { ns: 'app' })}</span>
<Button
ref={resolvedBrowseButtonRef}
variant="ghost-accent"
size="small"
className="ml-1 h-6 px-1 text-sm font-normal hover:bg-transparent hover:not-data-disabled:underline"
disabled={disabled}
onClick={selectHandle}
>
{t(($) => $['dslUploader.browse'], { ns: 'app' })}
</button>
</Button>
</div>
</div>
{dragging && <div ref={dragRef} className="absolute top-0 left-0 size-full" />}
@ -125,28 +161,47 @@ const Uploader: FC<Props> = ({
)}
{file && (
<div
ref={fileRowRef}
role="group"
tabIndex={-1}
aria-labelledby={fileNameId}
aria-describedby={fileMetadataId}
className={cn(
'group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs',
'group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'hover:bg-components-panel-on-panel-item-bg-hover',
)}
>
<div className="flex items-center justify-center p-3">
<YamlIcon className="size-6 shrink-0" />
<span aria-hidden className="i-custom-public-files-yaml size-6 shrink-0" />
</div>
<div className="flex grow flex-col items-start gap-0.5 py-1 pr-2">
<span className="font-inter max-w-[calc(100%-30px)] overflow-hidden text-[12px] leading-4 font-medium text-ellipsis whitespace-nowrap text-text-secondary">
<span
id={fileNameId}
className="font-inter max-w-[calc(100%-30px)] overflow-hidden text-[12px] leading-4 font-medium text-ellipsis whitespace-nowrap text-text-secondary"
>
{file.name}
</span>
<div className="font-inter flex h-3 items-center gap-1 self-stretch text-[10px] leading-3 font-medium text-text-tertiary uppercase">
<div
id={fileMetadataId}
className="font-inter flex h-3 items-center gap-1 self-stretch text-[10px] leading-3 font-medium text-text-tertiary uppercase"
>
<span>{displayName}</span>
<span className="text-text-quaternary">·</span>
<span>{formatFileSize(file.size)}</span>
</div>
</div>
<div className="hidden items-center pr-3 group-hover:flex">
<ActionButton onClick={removeFile}>
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</ActionButton>
<div className="flex items-center pr-3 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100">
<Button
ref={removeButtonRef}
variant="ghost"
size="small"
className="size-6 p-0"
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
disabled={disabled}
onClick={removeFile}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 text-text-tertiary" />
</Button>
</div>
</div>
)}
@ -154,5 +209,3 @@ const Uploader: FC<Props> = ({
</div>
)
}
export default React.memo(Uploader)

View File

@ -1,181 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"fill": "none",
"height": "26",
"viewBox": "0 0 24 26",
"width": "24",
"xmlns": "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink"
},
"children": [
{
"type": "element",
"name": "filter",
"attributes": {
"id": "a",
"color-interpolation-filters": "sRGB",
"filterUnits": "userSpaceOnUse",
"height": "26",
"width": "22",
"x": "1",
"y": "0"
},
"children": [
{
"type": "element",
"name": "feFlood",
"attributes": {
"flood-opacity": "0",
"result": "BackgroundImageFix"
},
"children": []
},
{
"type": "element",
"name": "feColorMatrix",
"attributes": {
"in": "SourceAlpha",
"result": "hardAlpha",
"type": "matrix",
"values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
},
"children": []
},
{
"type": "element",
"name": "feOffset",
"attributes": {
"dy": "1"
},
"children": []
},
{
"type": "element",
"name": "feGaussianBlur",
"attributes": {
"stdDeviation": "1"
},
"children": []
},
{
"type": "element",
"name": "feColorMatrix",
"attributes": {
"type": "matrix",
"values": "0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.05 0"
},
"children": []
},
{
"type": "element",
"name": "feBlend",
"attributes": {
"in2": "BackgroundImageFix",
"mode": "normal",
"result": "effect1_dropShadow_7605_8828"
},
"children": []
},
{
"type": "element",
"name": "feBlend",
"attributes": {
"in": "SourceGraphic",
"in2": "effect1_dropShadow_7605_8828",
"mode": "normal",
"result": "shape"
},
"children": []
}
]
},
{
"type": "element",
"name": "g",
"attributes": {
"filter": "url(#a)"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "m3 5.8c0-1.68016 0-2.52024.32698-3.16197.28762-.56449.74656-1.02343 1.31105-1.31105.64173-.32698 1.48181-.32698 3.16197-.32698h6.2l7 7v10.2c0 1.6802 0 2.5202-.327 3.162-.2876.5645-.7465 1.0234-1.311 1.311-.6418.327-1.4818.327-3.162.327h-8.4c-1.68016 0-2.52024 0-3.16197-.327-.56449-.2876-1.02343-.7465-1.31105-1.311-.32698-.6418-.32698-1.4818-.32698-3.162z",
"fill": "#e8eaed"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "m16.2 22.75h-8.4c-.8442 0-1.46232-.0002-1.95004-.04-.48479-.0397-.81868-.1172-1.09843-.2597-.51745-.2637-.93815-.6844-1.2018-1.2018-.14254-.2798-.22008-.6137-.25969-1.0985-.03985-.4877-.04004-1.1058-.04004-1.95v-12.4c0-.8442.00019-1.46232.04004-1.95004.03961-.48479.11715-.81868.25969-1.09843.26365-.51745.68435-.93815 1.2018-1.2018.27975-.14254.61364-.22008 1.09843-.25969.48772-.03985 1.10584-.04004 1.95004-.04004h6.0964l6.8536 6.85355v10.09645c0 .8442-.0002 1.4623-.04 1.95-.0397.4848-.1172.8187-.2597 1.0985-.2637.5174-.6844.9381-1.2018 1.2018-.2798.1425-.6137.22-1.0985.2597-.4877.0398-1.1058.04-1.95.04z",
"stroke": "#000",
"stroke-opacity": ".03",
"stroke-width": ".5"
},
"children": []
}
]
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "m14 1 7 7h-5c-1.1046 0-2-.89543-2-2z",
"fill": "#fff",
"opacity": ".5"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "m11.5264 9-2.15191 3.2267v2.0455h-1.31897v-2.0455l-2.05552-3.2267h1.48242l1.30707 2.0776 1.31781-2.0776z",
"fill": "#000"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "m13.7426 13.1121h-2.3874l-.4855 1.1724h-1.0572l2.2355-5.27223h1.0813l2.1448 5.27223h-1.1297zm-.3966-1.0526-.7318-1.9348-.8165 1.9348z",
"fill": "#cb171e"
},
"children": []
},
{
"type": "element",
"name": "g",
"attributes": {
"fill": "#000"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "m8.05469 14.8635v5.1673h1.10866v-3.5643l1.16025 2.3957h.8727l1.1999-2.4799v3.6474h1.0636v-5.1662h-1.4522l-1.2885 2.3369-1.22722-2.3369z"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "m17.9994 18.9079h-2.7272v-4.0456h-1.1296v5.1451h3.8568z"
},
"children": []
}
]
}
]
},
"name": "Yaml"
}

View File

@ -1,18 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Yaml.json'
const Icon = ({
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
}) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Yaml'
export default Icon

View File

@ -8,4 +8,3 @@ export { default as Pdf } from './Pdf'
export { default as Txt } from './Txt'
export { default as Unknown } from './Unknown'
export { default as Xlsx } from './Xlsx'
export { default as Yaml } from './Yaml'

View File

@ -90,7 +90,7 @@ vi.mock('@/service/workflow', () => ({
}))
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ updateFile }: { updateFile: (file?: File) => void }) => (
Uploader: ({ updateFile }: { updateFile: (file?: File) => void }) => (
<div data-testid="uploader">
<input
type="file"

View File

@ -5,7 +5,7 @@ import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { RiAlertFill, RiCloseLine, RiFileDownloadLine } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import { useUpdateDSLModal } from '../hooks/use-update-dsl-modal'
import VersionMismatchModal from './version-mismatch-modal'

View File

@ -48,7 +48,7 @@ vi.mock('@/service/use-snippets', () => ({
}))
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => (
Uploader: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => (
<button type="button" onClick={() => updateFile(new File(['name: snippet'], 'snippet.yml'))}>
{file?.name || 'select-dsl-file'}
</button>

View File

@ -19,7 +19,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useRouter } from '@/next/navigation'
import {

View File

@ -66,7 +66,7 @@ vi.mock('@/app/components/app/store', () => ({
}))
vi.mock('@/app/components/app/create-from-dsl-modal/uploader', () => ({
default: ({ updateFile }: { updateFile: (file?: File) => void }) => (
Uploader: ({ updateFile }: { updateFile: (file?: File) => void }) => (
<input
data-testid="dsl-file-input"
type="file"

View File

@ -8,7 +8,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { RiAlertFill, RiCloseLine, RiFileDownloadLine } from '@remixicon/react'
import { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import { useStore as useAppStore } from '@/app/components/app/store'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useEventEmitterContextContext } from '@/context/event-emitter'

View File

@ -10,7 +10,7 @@ import { Input } from '@langgenius/dify-ui/input'
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import {

View File

@ -4,7 +4,7 @@ import type { ReleaseSourceMode } from '../state'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { Uploader } from '@/app/components/app/create-from-dsl-modal/uploader'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
import {
createReleaseDslFileFieldAtom,

View File

@ -1,8 +1,8 @@
import type { DSLImportWarning } from '@/models/app'
import type { DslImportWarning } from '@dify/contracts/api/console/apps/types.gen'
const MAX_VISIBLE_IMPORT_WARNINGS = 3
export const getDSLImportWarningDescription = (warnings: DSLImportWarning[] = []) => {
export const getDSLImportWarningDescription = (warnings: DslImportWarning[] = []) => {
const messages = [...new Set(warnings.map((warning) => warning.message.trim()).filter(Boolean))]
if (!messages.length) return