mirror of
https://github.com/langgenius/dify.git
synced 2026-04-19 18:27:27 +08:00
feat: add sandbox corner mark
This commit is contained in:
@ -352,6 +352,18 @@ describe('AppCard', () => {
|
||||
expect(screen.getByTestId('app-type-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the sandbox corner mark when the app uses sandboxed runtime', () => {
|
||||
renderAppCard({ runtime_type: 'sandboxed' })
|
||||
|
||||
expect(screen.getByText('app.sandboxBadge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render the sandbox corner mark for classic runtime apps', () => {
|
||||
renderAppCard({ runtime_type: 'classic' })
|
||||
|
||||
expect(screen.queryByText('app.sandboxBadge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call getRedirection when the main card button is clicked', () => {
|
||||
const { app } = renderAppCard()
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import CornerMark from '@/app/components/plugins/card/base/corner-mark'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
@ -441,11 +442,18 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
|
||||
.filter(user => !!user.id)
|
||||
}, [onlineUsers])
|
||||
|
||||
const isSandboxApp = app.runtime_type === 'sandboxed'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group relative col-span-1 inline-flex h-[160px] flex-col rounded-xl border-[1px] border-solid border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg"
|
||||
>
|
||||
{isSandboxApp && (
|
||||
<div className="pointer-events-none">
|
||||
<CornerMark text={t('sandboxBadge', { ns: 'app' })} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => getRedirection(isCurrentWorkspaceEditor, app, push)}
|
||||
|
||||
@ -4,7 +4,7 @@ const CornerMark = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<div className="absolute right-0 top-0 flex pl-[13px]">
|
||||
<LeftCorner className="text-background-section" />
|
||||
<div className="h-5 rounded-tr-xl bg-background-section pr-2 leading-5 text-text-tertiary system-2xs-medium-uppercase">{text}</div>
|
||||
<div className="h-5 rounded-tr-xl bg-background-section pr-2 !leading-5 text-text-tertiary system-2xs-medium-uppercase">{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { $createParagraphNode, $createTextNode, $getRoot } from 'lexical'
|
||||
import * as React from 'react'
|
||||
import FilePickerBlock from '../file-picker-block'
|
||||
|
||||
vi.mock('@/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetTreeData: () => ({
|
||||
data: { children: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockDOMRect = {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 100,
|
||||
right: 200,
|
||||
bottom: 120,
|
||||
left: 100,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Range.prototype.getClientRects = vi.fn(() => {
|
||||
const rectList = [mockDOMRect] as unknown as DOMRectList
|
||||
Object.defineProperty(rectList, 'length', { value: 1 })
|
||||
Object.defineProperty(rectList, 'item', { value: (index: number) => (index === 0 ? mockDOMRect : null) })
|
||||
return rectList
|
||||
})
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
|
||||
})
|
||||
|
||||
type Captures = {
|
||||
editor: LexicalEditor | null
|
||||
}
|
||||
|
||||
const CONTENT_EDITABLE_TEST_ID = 'file-picker-block-ce'
|
||||
|
||||
const CaptureEditor = ({ captures }: { captures: Captures }) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
React.useEffect(() => {
|
||||
captures.editor = editor
|
||||
}, [captures, editor])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const MinimalEditor = ({ captures }: { captures: Captures }) => {
|
||||
const initialConfig = React.useMemo(() => ({
|
||||
namespace: `file-picker-block-test-${Math.random().toString(16).slice(2)}`,
|
||||
onError: (e: Error) => {
|
||||
throw e
|
||||
},
|
||||
}), [])
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_TEST_ID} />}
|
||||
placeholder={null}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<CaptureEditor captures={captures} />
|
||||
<FilePickerBlock />
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForEditor(captures: Captures): Promise<LexicalEditor> {
|
||||
await waitFor(() => {
|
||||
expect(captures.editor).not.toBeNull()
|
||||
})
|
||||
return captures.editor as LexicalEditor
|
||||
}
|
||||
|
||||
async function setEditorText(editor: LexicalEditor, text: string): Promise<void> {
|
||||
await act(async () => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
const paragraph = $createParagraphNode()
|
||||
const textNode = $createTextNode(text)
|
||||
paragraph.append(textNode)
|
||||
root.append(paragraph)
|
||||
textNode.selectEnd()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function flushNextTick(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
}
|
||||
|
||||
describe('FilePickerBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Regression coverage for the slash-triggered picker lifecycle.
|
||||
describe('Typeahead Menu', () => {
|
||||
it('should keep the file picker panel rendered when slash opens the menu', async () => {
|
||||
const captures: Captures = { editor: null }
|
||||
|
||||
render(<MinimalEditor captures={captures} />)
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
|
||||
await setEditorText(editor, '/')
|
||||
|
||||
expect(await screen.findByText('workflow.skillEditor.referenceFiles')).toBeInTheDocument()
|
||||
|
||||
await flushNextTick()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.skillEditor.referenceFiles')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.skillSidebar.empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,91 @@
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useSelectOrDelete } from '@/app/components/base/prompt-editor/hooks'
|
||||
import FileReferenceBlock from '../component'
|
||||
|
||||
const mockEditor = {
|
||||
isEditable: vi.fn(() => true),
|
||||
update: vi.fn(),
|
||||
}
|
||||
|
||||
const mockRef = { current: null as HTMLDivElement | null }
|
||||
const mockNodeMap = new Map<string, AppAssetTreeView>()
|
||||
const mockWorkflowStoreState = {
|
||||
activeTabId: null,
|
||||
fileMetadata: new Map(),
|
||||
}
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: () => [mockEditor],
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/base/prompt-editor/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useSelectOrDelete: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetNodeMap: () => ({
|
||||
data: mockNodeMap,
|
||||
isLoading: false,
|
||||
}),
|
||||
useSkillAssetTreeData: () => ({
|
||||
data: { children: [] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => selector(mockWorkflowStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('../preview-context', () => ({
|
||||
useFilePreviewContext: (selector: (context: { enabled: boolean }) => boolean) => selector({ enabled: false }),
|
||||
}))
|
||||
|
||||
describe('FileReferenceBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEditor.isEditable.mockReturnValue(true)
|
||||
mockRef.current = null
|
||||
mockNodeMap.clear()
|
||||
mockNodeMap.set('file-1', {
|
||||
id: 'file-1',
|
||||
name: 'contract.pdf',
|
||||
path: '/contract.pdf',
|
||||
node_type: 'file',
|
||||
children: [],
|
||||
extension: 'pdf',
|
||||
size: 0,
|
||||
})
|
||||
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, false])
|
||||
})
|
||||
|
||||
// Click-triggered popover should remain visible after opening from the inline file block.
|
||||
describe('Picker Popover', () => {
|
||||
it('should keep the picker panel visible after pressing the inline file reference', async () => {
|
||||
render(
|
||||
<FileReferenceBlock
|
||||
nodeKey="node-1"
|
||||
resourceId="file-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(screen.getByText('contract.pdf'))
|
||||
})
|
||||
|
||||
expect(await screen.findByText('workflow.skillEditor.referenceFiles')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.skillEditor.referenceFiles')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.skillSidebar.empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -221,6 +221,7 @@
|
||||
"publishApp.title": "Who can access web app",
|
||||
"removeOriginal": "Delete the original app",
|
||||
"roadmap": "See our roadmap",
|
||||
"sandboxBadge": "Sandbox",
|
||||
"showMyCreatedAppsOnly": "Created by me",
|
||||
"skills.comingSoon": "Workspace skills coming soon. Stay tuned.",
|
||||
"skills.title": "Skills",
|
||||
|
||||
Reference in New Issue
Block a user