feat: add sandbox corner mark

This commit is contained in:
Joel
2026-03-27 15:15:16 +08:00
parent 43b1042285
commit 4c44fd7ca8
6 changed files with 245 additions and 1 deletions

View File

@ -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()

View File

@ -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)}

View File

@ -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>
)
}

View File

@ -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()
})
})
})
})

View File

@ -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()
})
})
})
})

View File

@ -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",