mirror of
https://github.com/langgenius/dify.git
synced 2026-07-23 12:08:33 +08:00
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:
parent
bd8f0104d6
commit
6736f4005b
@ -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": {
|
||||
|
||||
@ -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]!)
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
@ -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'
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user