mirror of
https://github.com/langgenius/dify.git
synced 2026-08-30 17:11:50 +08:00
feat(web): enhance app and agent card interactions (#41449)
This commit is contained in:
@@ -692,12 +692,23 @@ describe('AppCard', () => {
|
||||
expect(screen.getByRole('listitem')).toContainElement(cardLink)
|
||||
})
|
||||
|
||||
it('should expose a visible focus ring on the card link', () => {
|
||||
it('should keep card navigation and actions as sibling focus targets', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppCard app={mockApp} />)
|
||||
const cardLink = screen.getByRole('link', { name: 'Test App' })
|
||||
|
||||
expect(cardLink).toHaveClass('focus-visible:ring-2')
|
||||
expect(cardLink).toHaveClass('focus-visible:ring-state-accent-solid')
|
||||
const cardLink = screen.getByRole('link', { name: 'Test App' })
|
||||
const starToggle = screen.getByRole('button', { name: 'app.studio.starApp: Test App' })
|
||||
const operationsTrigger = getOperationsTrigger()
|
||||
|
||||
expect(cardLink).not.toContainElement(starToggle)
|
||||
expect(cardLink).not.toContainElement(operationsTrigger)
|
||||
|
||||
await user.tab()
|
||||
expect(cardLink).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(starToggle).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(operationsTrigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should star the app from the card action without navigating', async () => {
|
||||
@@ -740,20 +751,6 @@ describe('AppCard', () => {
|
||||
})
|
||||
|
||||
describe('Operations Menu', () => {
|
||||
it('should reveal operations trigger when card receives keyboard focus', () => {
|
||||
render(<AppCard app={mockApp} />)
|
||||
const operationsTrigger = getOperationsTrigger()
|
||||
const operationsTriggerWrapper = operationsTrigger.closest('.absolute')
|
||||
|
||||
expect(operationsTriggerWrapper).toHaveClass('top-2')
|
||||
expect(operationsTriggerWrapper).toHaveClass('right-2')
|
||||
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:pointer-events-auto')
|
||||
expect(operationsTriggerWrapper).toHaveClass('group-focus-within:opacity-100')
|
||||
expect(operationsTriggerWrapper).not.toHaveClass('w-[120px]')
|
||||
expect(operationsTrigger).toHaveClass('focus-visible:ring-2')
|
||||
expect(operationsTrigger).toHaveClass('focus-visible:ring-state-accent-solid')
|
||||
})
|
||||
|
||||
it('should show edit option when dropdown menu is opened', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppCard app={mockApp} />)
|
||||
@@ -766,6 +763,32 @@ describe('AppCard', () => {
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose the same operations from the card context menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppCard app={mockApp} />)
|
||||
|
||||
await user.pointer({
|
||||
target: screen.getByRole('link', { name: 'Test App' }),
|
||||
keys: '[MouseRight]',
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'app.editApp' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'app.duplicate' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'app.export' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep card actions outside the card context menu trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppCard app={mockApp} />)
|
||||
|
||||
await user.pointer({
|
||||
target: screen.getByRole('button', { name: 'app.studio.starApp: Test App' }),
|
||||
keys: '[MouseRight]',
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: 'app.editApp' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show duplicate option when dropdown menu is opened', async () => {
|
||||
render(<AppCard app={mockApp} />)
|
||||
|
||||
@@ -1203,6 +1226,7 @@ describe('AppCard', () => {
|
||||
render(<AppCard app={mockApp} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'common.operation.exporting' })
|
||||
|
||||
expect(trigger).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { StepByStepTourSessionState } from '@/app/components/step-by-step-t
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import * as React from 'react'
|
||||
@@ -398,12 +398,11 @@ vi.mock('../app-card', () => ({
|
||||
stepByStepTourCardHighlightPart?: string
|
||||
}) => {
|
||||
return React.createElement(
|
||||
'div',
|
||||
'li',
|
||||
{
|
||||
'data-testid': `app-card-${app.id}`,
|
||||
'data-step-by-step-tour-target': stepByStepTourCardTarget,
|
||||
'data-step-by-step-tour-highlight-part': stepByStepTourCardHighlightPart,
|
||||
role: 'article',
|
||||
},
|
||||
app.name,
|
||||
React.createElement('button', {
|
||||
@@ -415,20 +414,27 @@ vi.mock('../app-card', () => ({
|
||||
)
|
||||
},
|
||||
default: ({ app }: { app: { id: string; name: string } }) => {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': `app-card-${app.id}`, role: 'article' },
|
||||
app.name,
|
||||
)
|
||||
return React.createElement('li', { 'data-testid': `app-card-${app.id}` }, app.name)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../app-card/action-bar', () => ({
|
||||
AppCardActionBar: ({ app }: { app: { id: string; name: string } }) => {
|
||||
return React.createElement('button', {
|
||||
'aria-label': `Actions for ${app.name}`,
|
||||
type: 'button',
|
||||
})
|
||||
vi.mock('../app-card/interactions', () => ({
|
||||
AppCardInteractions: ({
|
||||
app,
|
||||
children,
|
||||
}: {
|
||||
app: { id: string; name: string }
|
||||
children: React.ReactElement
|
||||
}) => {
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
children,
|
||||
React.createElement('button', {
|
||||
'aria-label': `Actions for ${app.name}`,
|
||||
type: 'button',
|
||||
}),
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -690,14 +696,20 @@ describe('List', () => {
|
||||
renderList()
|
||||
|
||||
const starredLabel = screen.getByRole('heading', { level: 2, name: 'Starred' })
|
||||
const starredList = screen.getByRole('list', { name: 'Starred' })
|
||||
const starredCard = screen.getByRole('link', { name: 'Starred App' })
|
||||
const allAppsLabel = screen.getByRole('heading', { level: 2, name: 'All Apps' })
|
||||
const allAppsList = screen.getByRole('list', { name: 'All Apps' })
|
||||
const firstAppCard = screen.getByTestId('app-card-app-1')
|
||||
const actionBar = screen.getByRole('button', { name: 'Actions for Starred App' })
|
||||
|
||||
expect(starredCard).toBeInTheDocument()
|
||||
expect(actionBar).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('list')).toHaveLength(2)
|
||||
expect(starredList).toContainElement(starredCard)
|
||||
expect(within(starredList).getAllByRole('listitem')).toHaveLength(1)
|
||||
expect(firstAppCard.parentElement).toBe(allAppsList)
|
||||
expect(within(allAppsList).getAllByRole('listitem')).toHaveLength(2)
|
||||
expect(
|
||||
starredLabel.compareDocumentPosition(starredCard) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
@@ -939,7 +951,10 @@ describe('List', () => {
|
||||
expect(screen.getByTestId('empty-state'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps')
|
||||
expect(screen.getByRole('status')).toHaveClass('sr-only')
|
||||
expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'false')
|
||||
expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute(
|
||||
'aria-busy',
|
||||
'false',
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
|
||||
@@ -953,7 +968,10 @@ describe('List', () => {
|
||||
renderList('?keywords=missing+app')
|
||||
|
||||
expect(screen.getByRole('status')).toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute(
|
||||
'aria-busy',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep the settled empty status during a background refetch', () => {
|
||||
@@ -963,7 +981,10 @@ describe('List', () => {
|
||||
renderList('?keywords=missing+app')
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('app.filterEmpty.noApps')
|
||||
expect(screen.getByTestId('empty-state').parentElement).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.getByTestId('empty-state').closest('[aria-busy]')).toHaveAttribute(
|
||||
'aria-busy',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('should leave the first empty state as soon as a filter changes', () => {
|
||||
|
||||
@@ -17,7 +17,8 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
|
||||
return (
|
||||
<>
|
||||
{skeletonKeys.map((key) => (
|
||||
<div
|
||||
<li
|
||||
aria-hidden
|
||||
key={key}
|
||||
className="h-40 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4 shadow-xs"
|
||||
>
|
||||
@@ -34,7 +35,7 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
|
||||
<SkeletonRectangle className="h-3 w-4/5 animate-pulse" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AppPartial,
|
||||
EnvironmentVariableItemResponse,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FormEventHandler } from 'react'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Toggle } from '@langgenius/dify-ui/toggle'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl'
|
||||
import StarIcon from '@/app/components/base/icons/src/vender/Star'
|
||||
import {
|
||||
getStepByStepTourDropdownMenuContentProps,
|
||||
useStepByStepTourControlledDropdown,
|
||||
} from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import {
|
||||
getAppACLCapabilities,
|
||||
hasOnlyAppPreviewPermission,
|
||||
hasPermission,
|
||||
} from '@/utils/permission'
|
||||
import { AppCardOperationsMenuContent } from '../operations-menu'
|
||||
|
||||
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DSLExportConfirmModal = dynamic(
|
||||
() => import('@/app/components/workflow/dsl-export-confirm-modal'),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
)
|
||||
|
||||
const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]'
|
||||
|
||||
type AppCardActionBarProps = {
|
||||
app: AppPartial
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
}
|
||||
|
||||
export const AppCardActionBar = memo(
|
||||
({
|
||||
app,
|
||||
stepByStepTourActionMenuOpen = false,
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
}: AppCardActionBarProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: currentUserId } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile.id,
|
||||
})
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const { push } = useRouter()
|
||||
const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions())
|
||||
const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions())
|
||||
const { mutate: deleteApp, isPending: isDeleting } = useMutation(
|
||||
consoleQuery.apps.byAppId.delete.mutationOptions(),
|
||||
)
|
||||
const { mutate: starApp, isPending: isStarring } = useMutation(
|
||||
consoleQuery.apps.byAppId.star.post.mutationOptions(),
|
||||
)
|
||||
const { mutate: unstarApp, isPending: isUnstarring } = useMutation(
|
||||
consoleQuery.apps.byAppId.star.delete.mutationOptions(),
|
||||
)
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const operationsMenu = useStepByStepTourControlledDropdown({
|
||||
allowTriggerCloseWhileControlled: false,
|
||||
controlledOpen: stepByStepTourActionMenuOpen,
|
||||
})
|
||||
const isOperationsMenuOpen = operationsMenu.open
|
||||
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariableItemResponse[]>([])
|
||||
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
|
||||
const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } =
|
||||
useExportWorkflowAppDsl()
|
||||
const isExporting = isAppDslExporting || isWorkflowAppDslExporting
|
||||
const isTogglingStar = isStarring || isUnstarring
|
||||
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
|
||||
const resourceMaintainer = app.maintainer ?? undefined
|
||||
const maintainerPermissionOptions = useMemo(
|
||||
() => ({
|
||||
currentUserId,
|
||||
resourceMaintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
}),
|
||||
[currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys],
|
||||
)
|
||||
const appACLCapabilities = useMemo(
|
||||
() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions),
|
||||
[app.permission_keys, maintainerPermissionOptions],
|
||||
)
|
||||
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
|
||||
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
try {
|
||||
deleteApp(
|
||||
{ params: { app_id: app.id } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $.appDeleted, { ns: 'app' }))
|
||||
onPlanInfoChanged()
|
||||
setShowConfirmDelete(false)
|
||||
setConfirmDeleteInput('')
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
toast.error(
|
||||
`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`)
|
||||
}
|
||||
}, [app.id, deleteApp, onPlanInfoChanged, t])
|
||||
|
||||
const onDeleteDialogOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (isDeleting) return
|
||||
|
||||
setShowConfirmDelete(open)
|
||||
if (!open) setConfirmDeleteInput('')
|
||||
},
|
||||
[isDeleting],
|
||||
)
|
||||
|
||||
const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name
|
||||
|
||||
const onDeleteDialogSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
if (isDeleteConfirmDisabled) return
|
||||
|
||||
void onConfirmDelete()
|
||||
},
|
||||
[isDeleteConfirmDisabled, onConfirmDelete],
|
||||
)
|
||||
|
||||
const handleShowEditModal = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowEditModal(true)
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleShowDuplicateModal = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowDuplicateModal(true)
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleShowSwitchModal = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowSwitchModal(true)
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleShowDeleteConfirm = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowConfirmDelete(true)
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleOpenAccessConfig = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
push(`/app/${app.id}/access-config`)
|
||||
}, [app.id, push, setIsOperationsMenuOpen])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(
|
||||
async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
}) => {
|
||||
try {
|
||||
await updateApp({
|
||||
params: { app_id: app.id },
|
||||
body: {
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
},
|
||||
})
|
||||
setShowEditModal(false)
|
||||
toast.success(t(($) => $.editDone, { ns: 'app' }))
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' }))
|
||||
}
|
||||
},
|
||||
[app.id, t, updateApp],
|
||||
)
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
}) => {
|
||||
try {
|
||||
copyApp(
|
||||
{
|
||||
params: { app_id: app.id },
|
||||
body: {
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (newApp) => {
|
||||
if (!('mode' in newApp)) {
|
||||
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
|
||||
return
|
||||
}
|
||||
|
||||
setShowDuplicateModal(false)
|
||||
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
|
||||
onPlanInfoChanged()
|
||||
getRedirection(newApp, push, {
|
||||
currentUserId,
|
||||
resourceMaintainer: newApp.maintainer ?? undefined,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
})
|
||||
},
|
||||
onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })),
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const onExport = async (include = false) => {
|
||||
await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include })
|
||||
}
|
||||
|
||||
const exportCheck = async () => {
|
||||
if (isExporting) return
|
||||
|
||||
setIsOperationsMenuOpen(false)
|
||||
const isWorkflowApp =
|
||||
app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const result = isWorkflowApp
|
||||
? await exportWorkflowAppDsl({ appId: app.id, appName: app.name })
|
||||
: await exportAppDsl({ appId: app.id, appName: app.name })
|
||||
if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList)
|
||||
}
|
||||
|
||||
const handleToggleStar = useCallback(
|
||||
(pressed: boolean) => {
|
||||
if (isTogglingStar) return
|
||||
|
||||
const mutateStar = pressed ? starApp : unstarApp
|
||||
try {
|
||||
mutateStar(
|
||||
{ params: { app_id: app.id } },
|
||||
{
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $['studio.starFailed'], { ns: 'app' }),
|
||||
),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $['studio.starFailed'], { ns: 'app' }),
|
||||
)
|
||||
}
|
||||
},
|
||||
[app.id, isTogglingStar, starApp, t, unstarApp],
|
||||
)
|
||||
|
||||
const shouldShowEditOption = appACLCapabilities.canEdit
|
||||
const shouldShowDuplicateOption = canCreateApp
|
||||
const shouldShowExportOption = appACLCapabilities.canImportExportDSL
|
||||
const shouldShowSwitchOption =
|
||||
appACLCapabilities.canEdit &&
|
||||
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
|
||||
const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig
|
||||
const shouldShowDeleteOption = appACLCapabilities.canDelete
|
||||
const shouldShowOperationsMenu =
|
||||
shouldShowEditOption ||
|
||||
shouldShowDuplicateOption ||
|
||||
shouldShowExportOption ||
|
||||
shouldShowSwitchOption ||
|
||||
shouldShowAccessConfigOption ||
|
||||
shouldShowDeleteOption
|
||||
const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' })
|
||||
const starToggleAccessibleLabel = `${starToggleLabel}: ${app.name}`
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isPreviewOnly && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
|
||||
isOperationsMenuOpen || isExporting
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
pressed={app.is_starred}
|
||||
disabled={isTogglingStar}
|
||||
onPressedChange={handleToggleStar}
|
||||
render={
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={starToggleAccessibleLabel}
|
||||
className="group disabled:opacity-70"
|
||||
>
|
||||
<StarIcon
|
||||
aria-hidden
|
||||
className="size-4.5 text-text-tertiary group-data-pressed:text-text-warning-secondary"
|
||||
/>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{starToggleLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
{shouldShowOperationsMenu && (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isOperationsMenuOpen}
|
||||
onOpenChange={setIsOperationsMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={
|
||||
isExporting
|
||||
? t(($) => $['operation.exporting'], { ns: 'common' })
|
||||
: t(($) => $['operation.moreActionsFor'], {
|
||||
ns: 'common',
|
||||
name: app.name,
|
||||
})
|
||||
}
|
||||
disabled={isExporting}
|
||||
className="data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-4.5 text-text-tertiary',
|
||||
isExporting
|
||||
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
|
||||
: 'i-ri-more-fill',
|
||||
)}
|
||||
/>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
highlightPart: stepByStepTourActionMenuHighlightPart,
|
||||
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
|
||||
className: OPERATIONS_MENU_POPUP_CLASS_NAME,
|
||||
})}
|
||||
>
|
||||
<AppCardOperationsMenuContent
|
||||
app={app}
|
||||
shouldShowEditOption={shouldShowEditOption}
|
||||
shouldShowDuplicateOption={shouldShowDuplicateOption}
|
||||
shouldShowExportOption={shouldShowExportOption}
|
||||
shouldShowSwitchOption={shouldShowSwitchOption}
|
||||
shouldShowAccessConfigOption={shouldShowAccessConfigOption}
|
||||
shouldShowDeleteOption={shouldShowDeleteOption}
|
||||
isExporting={isExporting}
|
||||
onEdit={handleShowEditModal}
|
||||
onDuplicate={handleShowDuplicateModal}
|
||||
onExport={exportCheck}
|
||||
onSwitch={handleShowSwitchModal}
|
||||
onDelete={handleShowDeleteConfirm}
|
||||
onAccessConfig={handleOpenAccessConfig}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showEditModal && (
|
||||
<EditAppModal
|
||||
isEditModal
|
||||
appName={app.name}
|
||||
appIconType={appIconType}
|
||||
appIcon={app.icon ?? ''}
|
||||
appIconBackground={app.icon_background}
|
||||
appIconUrl={app.icon_url}
|
||||
appDescription={app.description ?? ''}
|
||||
appMode={app.mode}
|
||||
appUseIconAsAnswerIcon={app.use_icon_as_answer_icon ?? false}
|
||||
max_active_requests={app.max_active_requests ?? null}
|
||||
show={showEditModal}
|
||||
onConfirm={onEdit}
|
||||
onHide={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDuplicateModal && (
|
||||
<DuplicateAppModal
|
||||
appName={app.name}
|
||||
icon_type={appIconType}
|
||||
icon={app.icon ?? ''}
|
||||
icon_background={app.icon_background}
|
||||
icon_url={app.icon_url}
|
||||
show={showDuplicateModal}
|
||||
onConfirm={onCopy}
|
||||
onHide={() => setShowDuplicateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showSwitchModal && (
|
||||
<SwitchAppModal
|
||||
show={showSwitchModal}
|
||||
appDetail={app}
|
||||
onClose={() => setShowSwitchModal(false)}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog open={showConfirmDelete} onOpenChange={onDeleteDialogOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<form className="flex flex-col" onSubmit={onDeleteDialogSubmit}>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $.deleteAppConfirmTitle, { ns: 'app' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $.deleteAppConfirmContent, { ns: 'app' })}
|
||||
</AlertDialogDescription>
|
||||
<Field name="confirm-app-name" className="mt-2">
|
||||
<FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary">
|
||||
<Trans
|
||||
i18nKey={($) => $.deleteAppConfirmInputLabel}
|
||||
ns="app"
|
||||
values={{ appName: app.name }}
|
||||
components={{
|
||||
appName: (
|
||||
<span className="system-sm-semibold text-text-primary" translate="no" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FieldLabel>
|
||||
<InputGroup className="border-components-input-border-hover">
|
||||
<InputGroupInput
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t(($) => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })}
|
||||
value={confirmDeleteInput}
|
||||
onValueChange={setConfirmDeleteInput}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="min-w-20 justify-end pe-1.75">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={() => setConfirmDeleteInput(app.name)}
|
||||
className="rounded-full px-2.5"
|
||||
>
|
||||
{t(($) => $['operation.fill'], { ns: 'common' })}
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton type="button" disabled={isDeleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
type="submit"
|
||||
loading={isDeleting}
|
||||
disabled={isDeleteConfirmDisabled}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{secretEnvList.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvList}
|
||||
onConfirm={onExport}
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
hasPermission,
|
||||
} from '@/utils/permission'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { AppCardActionBar } from './action-bar'
|
||||
import { AppCardInteractions } from './interactions'
|
||||
|
||||
const EMPTY_ONLINE_USERS: WorkflowOnlineUser[] = []
|
||||
|
||||
@@ -120,10 +120,8 @@ export const AppCard = memo(
|
||||
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
|
||||
const appHref = getRedirectionPath(app, maintainerPermissionOptions)
|
||||
const appCardClassName = cn(
|
||||
'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out',
|
||||
isPreviewOnly
|
||||
? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
: 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'inline-flex h-full w-full touch-manipulation flex-col rounded-xl outline-hidden',
|
||||
isPreviewOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
|
||||
)
|
||||
const showPreviewOnlyAccessWarning = useCallback(() => {
|
||||
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
|
||||
@@ -172,8 +170,7 @@ export const AppCard = memo(
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-6.5 shrink-0 items-start px-3" />
|
||||
<div className="flex min-w-0 shrink-0 items-center overflow-hidden pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
|
||||
<div className="mt-6.5 flex min-w-0 shrink-0 items-center overflow-hidden pt-2 pr-4 pb-3 pl-4 system-xs-regular text-text-tertiary">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 whitespace-nowrap">
|
||||
{app.author_name && (
|
||||
<>
|
||||
@@ -188,7 +185,13 @@ export const AppCard = memo(
|
||||
)
|
||||
|
||||
return (
|
||||
<div role="listitem" className="group relative col-span-1 h-41.5">
|
||||
<li
|
||||
className={cn(
|
||||
"group relative isolate col-span-1 h-41.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid has-[>button:focus-visible]:after:inset-ring-2 has-[>button:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none",
|
||||
!isPreviewOnly &&
|
||||
'hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 [@media(hover:none)]:bg-components-card-bg-alt',
|
||||
)}
|
||||
>
|
||||
{isPreviewOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -202,23 +205,22 @@ export const AppCard = memo(
|
||||
{appCardContent}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={appHref}
|
||||
aria-labelledby={appNameId}
|
||||
aria-describedby={app.description ? appDescriptionId : undefined}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={appCardClassName}
|
||||
>
|
||||
{appCardContent}
|
||||
</Link>
|
||||
)}
|
||||
{!isPreviewOnly && (
|
||||
<AppCardActionBar
|
||||
<AppCardInteractions
|
||||
app={app}
|
||||
stepByStepTourActionMenuOpen={stepByStepTourActionMenuOpen}
|
||||
stepByStepTourActionMenuHighlightPart={stepByStepTourActionMenuHighlightPart}
|
||||
/>
|
||||
>
|
||||
<Link
|
||||
href={appHref}
|
||||
aria-labelledby={appNameId}
|
||||
aria-describedby={app.description ? appDescriptionId : undefined}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={appCardClassName}
|
||||
>
|
||||
{appCardContent}
|
||||
</Link>
|
||||
</AppCardInteractions>
|
||||
)}
|
||||
<div className="absolute top-26 right-3 left-3 flex h-6.5 min-w-0 items-start">
|
||||
<AppCardTags
|
||||
@@ -229,7 +231,7 @@ export const AppCard = memo(
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AppPartial,
|
||||
EnvironmentVariableItemResponse,
|
||||
} from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { FormEventHandler, MouseEvent, ReactElement } from 'react'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import { zIconType } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@langgenius/dify-ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Toggle } from '@langgenius/dify-ui/toggle'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl'
|
||||
import StarIcon from '@/app/components/base/icons/src/vender/Star'
|
||||
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import {
|
||||
getStepByStepTourDropdownMenuContentProps,
|
||||
useStepByStepTourControlledDropdown,
|
||||
} from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import { getAppACLCapabilities, hasPermission } from '@/utils/permission'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DSLExportConfirmModal = dynamic(
|
||||
() => import('@/app/components/workflow/dsl-export-confirm-modal'),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
)
|
||||
|
||||
const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]'
|
||||
const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set<AppPartial['mode']>([
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.WORKFLOW,
|
||||
])
|
||||
|
||||
function requiresPublishedWorkflowInExplore(app: AppPartial) {
|
||||
return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode)
|
||||
}
|
||||
|
||||
type AppCardOperationsMenuItemsProps = {
|
||||
app: AppPartial
|
||||
kind: 'context' | 'dropdown'
|
||||
shouldShowEditOption: boolean
|
||||
shouldShowDuplicateOption: boolean
|
||||
shouldShowExportOption: boolean
|
||||
shouldShowSwitchOption: boolean
|
||||
shouldShowAccessConfigOption: boolean
|
||||
shouldShowDeleteOption: boolean
|
||||
isExporting: boolean
|
||||
onEdit: () => void
|
||||
onDuplicate: () => void
|
||||
onExport: () => void
|
||||
onSwitch: () => void
|
||||
onDelete: () => void
|
||||
onAccessConfig: () => void
|
||||
}
|
||||
|
||||
function AppCardOperationsMenuItems({
|
||||
app,
|
||||
kind,
|
||||
shouldShowEditOption,
|
||||
shouldShowDuplicateOption,
|
||||
shouldShowExportOption,
|
||||
shouldShowSwitchOption,
|
||||
shouldShowAccessConfigOption,
|
||||
shouldShowDeleteOption,
|
||||
isExporting,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onExport,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onAccessConfig,
|
||||
}: AppCardOperationsMenuItemsProps) {
|
||||
const { t } = useTranslation()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
|
||||
appId: app.id,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
const needsPublishBeforeExplore = requiresPublishedWorkflowInExplore(app) && !app.workflow?.id
|
||||
const shouldShowOpenInExploreOption =
|
||||
!app.has_draft_trigger &&
|
||||
(needsPublishBeforeExplore ||
|
||||
!systemFeatures.webapp_auth.enabled ||
|
||||
(!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)))
|
||||
const hasEditGroup = shouldShowEditOption
|
||||
const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption
|
||||
const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption
|
||||
const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption
|
||||
const MenuItem = kind === 'context' ? ContextMenuItem : DropdownMenuItem
|
||||
const MenuSeparator = kind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
function handleMenuAction(event: MouseEvent<HTMLElement>, action: () => void) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
action()
|
||||
}
|
||||
|
||||
async function handleOpenInstalledApp(event: MouseEvent<HTMLElement>) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) {
|
||||
toast.error(t(($) => $.notPublishedYet, { ns: 'app' }))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await openAsyncWindow(
|
||||
async () => {
|
||||
const { installed_apps } = await fetchInstalledAppList(app.id)
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
|
||||
throw new Error(t(($) => $.notPublishedYet, { ns: 'app' }))
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`${error.message || error}`)
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : `${error}`
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldShowEditOption && (
|
||||
<MenuItem className="gap-2 px-3" onClick={(event) => handleMenuAction(event, onEdit)}>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.editApp, { ns: 'app' })}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{hasEditGroup &&
|
||||
(hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
|
||||
<MenuSeparator />
|
||||
)}
|
||||
{shouldShowDuplicateOption && (
|
||||
<MenuItem className="gap-2 px-3" onClick={(event) => handleMenuAction(event, onDuplicate)}>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.duplicate, { ns: 'app' })}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{shouldShowExportOption && (
|
||||
<MenuItem
|
||||
className="gap-2 px-3"
|
||||
disabled={isExporting}
|
||||
onClick={(event) => handleMenuAction(event, onExport)}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.export, { ns: 'app' })}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
|
||||
<MenuSeparator />
|
||||
)}
|
||||
{shouldShowSwitchOption && (
|
||||
<MenuItem className="gap-2 px-3" onClick={(event) => handleMenuAction(event, onSwitch)}>
|
||||
<span className="text-sm/5 text-text-secondary">{t(($) => $.switch, { ns: 'app' })}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{shouldShowOpenInExploreOption && (
|
||||
<MenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.openInExplore, { ns: 'app' })}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{hasSwitchOrExploreGroup && hasAccessDeleteGroup && <MenuSeparator />}
|
||||
{shouldShowAccessConfigOption && (
|
||||
<MenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={(event) => handleMenuAction(event, onAccessConfig)}
|
||||
>
|
||||
<span className="text-sm/5 text-text-secondary">
|
||||
{t(($) => $['settings.resourceAccess'], { ns: 'common' })}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{shouldShowAccessConfigOption && shouldShowDeleteOption && <MenuSeparator />}
|
||||
{shouldShowDeleteOption && (
|
||||
<MenuItem
|
||||
variant="destructive"
|
||||
className="gap-2 px-3"
|
||||
onClick={(event) => handleMenuAction(event, onDelete)}
|
||||
>
|
||||
<span className="system-sm-regular">
|
||||
{t(($) => $['operation.delete'], { ns: 'common' })}
|
||||
</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AppCardInteractionsProps = {
|
||||
app: AppPartial
|
||||
children: ReactElement
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
}
|
||||
|
||||
export function AppCardInteractions({
|
||||
app,
|
||||
children,
|
||||
stepByStepTourActionMenuOpen = false,
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
}: AppCardInteractionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: currentUserId } = useSuspenseQuery({
|
||||
...userProfileQueryOptions(),
|
||||
select: (data) => data.profile.id,
|
||||
})
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const { push } = useRouter()
|
||||
const { mutate: copyApp } = useMutation(consoleQuery.apps.byAppId.copy.post.mutationOptions())
|
||||
const { mutateAsync: updateApp } = useMutation(consoleQuery.apps.byAppId.put.mutationOptions())
|
||||
const { mutate: deleteApp, isPending: isDeleting } = useMutation(
|
||||
consoleQuery.apps.byAppId.delete.mutationOptions(),
|
||||
)
|
||||
const { mutate: starApp, isPending: isStarring } = useMutation(
|
||||
consoleQuery.apps.byAppId.star.post.mutationOptions(),
|
||||
)
|
||||
const { mutate: unstarApp, isPending: isUnstarring } = useMutation(
|
||||
consoleQuery.apps.byAppId.star.delete.mutationOptions(),
|
||||
)
|
||||
|
||||
const [activeDialog, setActiveDialog] = useState<
|
||||
'delete' | 'duplicate' | 'edit' | 'switch' | null
|
||||
>(null)
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const operationsMenu = useStepByStepTourControlledDropdown({
|
||||
allowTriggerCloseWhileControlled: false,
|
||||
controlledOpen: stepByStepTourActionMenuOpen,
|
||||
})
|
||||
const isOperationsMenuOpen = operationsMenu.open
|
||||
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariableItemResponse[]>([])
|
||||
const { exportAppDsl, isExporting: isAppDslExporting } = useExportAppDsl()
|
||||
const { exportWorkflowAppDsl, isExporting: isWorkflowAppDslExporting } = useExportWorkflowAppDsl()
|
||||
const isExporting = isAppDslExporting || isWorkflowAppDslExporting
|
||||
const isTogglingStar = isStarring || isUnstarring
|
||||
const appIconType = zIconType.safeParse(app.icon_type).data ?? null
|
||||
const resourceMaintainer = app.maintainer ?? undefined
|
||||
const maintainerPermissionOptions = useMemo(
|
||||
() => ({
|
||||
currentUserId,
|
||||
resourceMaintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
}),
|
||||
[currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys],
|
||||
)
|
||||
const appACLCapabilities = useMemo(
|
||||
() => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions),
|
||||
[app.permission_keys, maintainerPermissionOptions],
|
||||
)
|
||||
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
try {
|
||||
deleteApp(
|
||||
{ params: { app_id: app.id } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t(($) => $.appDeleted, { ns: 'app' }))
|
||||
onPlanInfoChanged()
|
||||
setActiveDialog(null)
|
||||
setConfirmDeleteInput('')
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
toast.error(
|
||||
`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`,
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
toast.error(`${t(($) => $.appDeleteFailed, { ns: 'app' })}${message ? `: ${message}` : ''}`)
|
||||
}
|
||||
}, [app.id, deleteApp, onPlanInfoChanged, t])
|
||||
|
||||
const onDeleteDialogOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (isDeleting) return
|
||||
|
||||
setActiveDialog(open ? 'delete' : null)
|
||||
if (!open) setConfirmDeleteInput('')
|
||||
},
|
||||
[isDeleting],
|
||||
)
|
||||
|
||||
const isDeleteConfirmDisabled = isDeleting || confirmDeleteInput !== app.name
|
||||
|
||||
const onDeleteDialogSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
if (isDeleteConfirmDisabled) return
|
||||
|
||||
void onConfirmDelete()
|
||||
},
|
||||
[isDeleteConfirmDisabled, onConfirmDelete],
|
||||
)
|
||||
|
||||
const handleShowEditModal = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setActiveDialog('edit')
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleShowDuplicateModal = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setActiveDialog('duplicate')
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleShowSwitchModal = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setActiveDialog('switch')
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleShowDeleteConfirm = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setActiveDialog('delete')
|
||||
})
|
||||
}, [setIsOperationsMenuOpen])
|
||||
|
||||
const handleOpenAccessConfig = useCallback(() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
push(`/app/${app.id}/access-config`)
|
||||
}, [app.id, push, setIsOperationsMenuOpen])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(
|
||||
async ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
}) => {
|
||||
try {
|
||||
await updateApp({
|
||||
params: { app_id: app.id },
|
||||
body: {
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
},
|
||||
})
|
||||
setActiveDialog(null)
|
||||
toast.success(t(($) => $.editDone, { ns: 'app' }))
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.editFailed, { ns: 'app' }))
|
||||
}
|
||||
},
|
||||
[app.id, t, updateApp],
|
||||
)
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = ({
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
}) => {
|
||||
try {
|
||||
copyApp(
|
||||
{
|
||||
params: { app_id: app.id },
|
||||
body: {
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (newApp) => {
|
||||
if (!('mode' in newApp)) {
|
||||
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
|
||||
return
|
||||
}
|
||||
|
||||
setActiveDialog(null)
|
||||
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
|
||||
onPlanInfoChanged()
|
||||
getRedirection(newApp, push, {
|
||||
currentUserId,
|
||||
resourceMaintainer: newApp.maintainer ?? undefined,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
})
|
||||
},
|
||||
onError: () => toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' })),
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const onExport = async (include = false) => {
|
||||
await exportAppDsl({ appId: app.id, appName: app.name, includeSecret: include })
|
||||
}
|
||||
|
||||
const exportCheck = async () => {
|
||||
if (isExporting) return
|
||||
|
||||
setIsOperationsMenuOpen(false)
|
||||
const isWorkflowApp =
|
||||
app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const result = isWorkflowApp
|
||||
? await exportWorkflowAppDsl({ appId: app.id, appName: app.name })
|
||||
: await exportAppDsl({ appId: app.id, appName: app.name })
|
||||
if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList)
|
||||
}
|
||||
|
||||
const handleToggleStar = useCallback(
|
||||
(pressed: boolean) => {
|
||||
if (isTogglingStar) return
|
||||
|
||||
const mutateStar = pressed ? starApp : unstarApp
|
||||
try {
|
||||
mutateStar(
|
||||
{ params: { app_id: app.id } },
|
||||
{
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $['studio.starFailed'], { ns: 'app' }),
|
||||
),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t(($) => $['studio.starFailed'], { ns: 'app' }),
|
||||
)
|
||||
}
|
||||
},
|
||||
[app.id, isTogglingStar, starApp, t, unstarApp],
|
||||
)
|
||||
|
||||
const shouldShowEditOption = appACLCapabilities.canEdit
|
||||
const shouldShowDuplicateOption = canCreateApp
|
||||
const shouldShowExportOption = appACLCapabilities.canImportExportDSL
|
||||
const shouldShowSwitchOption =
|
||||
appACLCapabilities.canEdit &&
|
||||
(app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT)
|
||||
const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig
|
||||
const shouldShowDeleteOption = appACLCapabilities.canDelete
|
||||
const shouldShowOperationsMenu =
|
||||
shouldShowEditOption ||
|
||||
shouldShowDuplicateOption ||
|
||||
shouldShowExportOption ||
|
||||
shouldShowSwitchOption ||
|
||||
shouldShowAccessConfigOption ||
|
||||
shouldShowDeleteOption
|
||||
const starToggleLabel = t(($) => $['studio.starApp'], { ns: 'app' })
|
||||
const starToggleAccessibleLabel = `${starToggleLabel}: ${app.name}`
|
||||
const operationsMenuItemsProps = {
|
||||
app,
|
||||
shouldShowEditOption,
|
||||
shouldShowDuplicateOption,
|
||||
shouldShowExportOption,
|
||||
shouldShowSwitchOption,
|
||||
shouldShowAccessConfigOption,
|
||||
shouldShowDeleteOption,
|
||||
isExporting,
|
||||
onEdit: handleShowEditModal,
|
||||
onDuplicate: handleShowDuplicateModal,
|
||||
onExport: exportCheck,
|
||||
onSwitch: handleShowSwitchModal,
|
||||
onDelete: handleShowDeleteConfirm,
|
||||
onAccessConfig: handleOpenAccessConfig,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldShowOperationsMenu ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={children} />
|
||||
<ContextMenuContent className={OPERATIONS_MENU_POPUP_CLASS_NAME}>
|
||||
<AppCardOperationsMenuItems kind="context" {...operationsMenuItemsProps} />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-[-0.5px] right-[-0.5px] flex h-16 w-30 items-start justify-end bg-[linear-gradient(67deg,var(--color-components-card-bg-alt-transparent)_0%,var(--color-components-card-bg-alt)_75%)] p-2 opacity-0',
|
||||
isOperationsMenuOpen || isExporting
|
||||
? 'opacity-100'
|
||||
: 'group-focus-within:opacity-100 group-hover:opacity-100 has-data-popup-open:opacity-100 [@media(hover:none)]:opacity-100',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs',
|
||||
isOperationsMenuOpen || isExporting
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none group-focus-within:pointer-events-auto group-hover:pointer-events-auto has-data-popup-open:pointer-events-auto [@media(hover:none)]:pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
pressed={app.is_starred}
|
||||
disabled={isTogglingStar}
|
||||
onPressedChange={handleToggleStar}
|
||||
render={
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={starToggleAccessibleLabel}
|
||||
className="group disabled:opacity-70"
|
||||
>
|
||||
<StarIcon
|
||||
aria-hidden
|
||||
className="size-4.5 text-text-tertiary group-data-pressed:text-text-warning-secondary"
|
||||
/>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{starToggleLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
{shouldShowOperationsMenu && (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isOperationsMenuOpen}
|
||||
onOpenChange={setIsOperationsMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={
|
||||
isExporting
|
||||
? t(($) => $['operation.exporting'], { ns: 'common' })
|
||||
: t(($) => $['operation.moreActionsFor'], {
|
||||
ns: 'common',
|
||||
name: app.name,
|
||||
})
|
||||
}
|
||||
disabled={isExporting}
|
||||
className="data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-4.5 text-text-tertiary',
|
||||
isExporting
|
||||
? 'i-ri-loader-2-line animate-spin motion-reduce:animate-none'
|
||||
: 'i-ri-more-fill',
|
||||
)}
|
||||
/>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
highlightPart: stepByStepTourActionMenuHighlightPart,
|
||||
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
|
||||
className: OPERATIONS_MENU_POPUP_CLASS_NAME,
|
||||
})}
|
||||
>
|
||||
<AppCardOperationsMenuItems kind="dropdown" {...operationsMenuItemsProps} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeDialog === 'edit' && (
|
||||
<EditAppModal
|
||||
isEditModal
|
||||
appName={app.name}
|
||||
appIconType={appIconType}
|
||||
appIcon={app.icon ?? ''}
|
||||
appIconBackground={app.icon_background}
|
||||
appIconUrl={app.icon_url}
|
||||
appDescription={app.description ?? ''}
|
||||
appMode={app.mode}
|
||||
appUseIconAsAnswerIcon={app.use_icon_as_answer_icon ?? false}
|
||||
max_active_requests={app.max_active_requests ?? null}
|
||||
show
|
||||
onConfirm={onEdit}
|
||||
onHide={() => setActiveDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{activeDialog === 'duplicate' && (
|
||||
<DuplicateAppModal
|
||||
appName={app.name}
|
||||
icon_type={appIconType}
|
||||
icon={app.icon ?? ''}
|
||||
icon_background={app.icon_background}
|
||||
icon_url={app.icon_url}
|
||||
show
|
||||
onConfirm={onCopy}
|
||||
onHide={() => setActiveDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{activeDialog === 'switch' && (
|
||||
<SwitchAppModal show appDetail={app} onClose={() => setActiveDialog(null)} />
|
||||
)}
|
||||
<AlertDialog open={activeDialog === 'delete'} onOpenChange={onDeleteDialogOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<form className="flex flex-col" onSubmit={onDeleteDialogSubmit}>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $.deleteAppConfirmTitle, { ns: 'app' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $.deleteAppConfirmContent, { ns: 'app' })}
|
||||
</AlertDialogDescription>
|
||||
<Field name="confirm-app-name" className="mt-2">
|
||||
<FieldLabel className="mb-1 block py-0 system-sm-regular text-text-secondary">
|
||||
<Trans
|
||||
i18nKey={($) => $.deleteAppConfirmInputLabel}
|
||||
ns="app"
|
||||
values={{ appName: app.name }}
|
||||
components={{
|
||||
appName: (
|
||||
<span className="system-sm-semibold text-text-primary" translate="no" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FieldLabel>
|
||||
<InputGroup className="border-components-input-border-hover">
|
||||
<InputGroupInput
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={t(($) => $.deleteAppConfirmInputPlaceholder, { ns: 'app' })}
|
||||
value={confirmDeleteInput}
|
||||
onValueChange={setConfirmDeleteInput}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end" className="min-w-20 justify-end pe-1.75">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={() => setConfirmDeleteInput(app.name)}
|
||||
className="rounded-full px-2.5"
|
||||
>
|
||||
{t(($) => $['operation.fill'], { ns: 'common' })}
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton type="button" disabled={isDeleting}>
|
||||
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
type="submit"
|
||||
loading={isDeleting}
|
||||
disabled={isDeleteConfirmDisabled}
|
||||
>
|
||||
{t(($) => $['operation.confirm'], { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{secretEnvList.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvList}
|
||||
onConfirm={onExport}
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set<AppPartial['mode']>([
|
||||
AppModeEnum.ADVANCED_CHAT,
|
||||
AppModeEnum.WORKFLOW,
|
||||
])
|
||||
|
||||
function requiresPublishedWorkflowInExplore(app: AppPartial) {
|
||||
return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode)
|
||||
}
|
||||
|
||||
type AppCardOperationsMenuProps = {
|
||||
app: AppPartial
|
||||
shouldShowEditOption: boolean
|
||||
shouldShowDuplicateOption: boolean
|
||||
shouldShowExportOption: boolean
|
||||
shouldShowSwitchOption: boolean
|
||||
shouldShowOpenInExploreOption: boolean
|
||||
shouldShowAccessConfigOption: boolean
|
||||
shouldShowDeleteOption: boolean
|
||||
isExporting: boolean
|
||||
onEdit: () => void
|
||||
onDuplicate: () => void
|
||||
onExport: () => void
|
||||
onSwitch: () => void
|
||||
onDelete: () => void
|
||||
onAccessConfig: () => void
|
||||
}
|
||||
|
||||
function AppCardOperationsMenu({
|
||||
app,
|
||||
shouldShowEditOption,
|
||||
shouldShowDuplicateOption,
|
||||
shouldShowExportOption,
|
||||
shouldShowSwitchOption,
|
||||
shouldShowOpenInExploreOption,
|
||||
shouldShowAccessConfigOption,
|
||||
shouldShowDeleteOption,
|
||||
isExporting,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onExport,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onAccessConfig,
|
||||
}: AppCardOperationsMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
const hasEditGroup = shouldShowEditOption
|
||||
const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption
|
||||
const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption
|
||||
const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption
|
||||
|
||||
function handleMenuAction(e: MouseEvent<HTMLElement>, action: () => void) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
action()
|
||||
}
|
||||
|
||||
async function handleOpenInstalledApp(e: MouseEvent<HTMLElement>) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) {
|
||||
toast.error(t(($) => $.notPublishedYet, { ns: 'app' }))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await openAsyncWindow(
|
||||
async () => {
|
||||
const { installed_apps } = await fetchInstalledAppList(app.id)
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
|
||||
throw new Error(t(($) => $.notPublishedYet, { ns: 'app' }))
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error(`${err.message || err}`)
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : `${e}`
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldShowEditOption && (
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onEdit)}>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.editApp, { ns: 'app' })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasEditGroup &&
|
||||
(hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{shouldShowDuplicateOption && (
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onDuplicate)}>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.duplicate, { ns: 'app' })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{shouldShowExportOption && (
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
disabled={isExporting}
|
||||
onClick={(e) => handleMenuAction(e, onExport)}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.export, { ns: 'app' })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{shouldShowSwitchOption && (
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={(e) => handleMenuAction(e, onSwitch)}>
|
||||
<span className="text-sm/5 text-text-secondary">{t(($) => $.switch, { ns: 'app' })}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{shouldShowOpenInExploreOption && (
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenInstalledApp}>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t(($) => $.openInExplore, { ns: 'app' })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasSwitchOrExploreGroup && hasAccessDeleteGroup && <DropdownMenuSeparator />}
|
||||
{shouldShowAccessConfigOption && (
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={(e) => handleMenuAction(e, onAccessConfig)}
|
||||
>
|
||||
<span className="text-sm/5 text-text-secondary">
|
||||
{t(($) => $['settings.resourceAccess'], { ns: 'common' })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{shouldShowAccessConfigOption && shouldShowDeleteOption && <DropdownMenuSeparator />}
|
||||
{shouldShowDeleteOption && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2 px-3"
|
||||
onClick={(e) => handleMenuAction(e, onDelete)}
|
||||
>
|
||||
<span className="system-sm-regular">
|
||||
{t(($) => $['operation.delete'], { ns: 'common' })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AppCardOperationsMenuContentProps = Omit<
|
||||
AppCardOperationsMenuProps,
|
||||
'shouldShowOpenInExploreOption'
|
||||
>
|
||||
|
||||
export function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
|
||||
appId: props.app.id,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
const needsPublishBeforeExplore =
|
||||
requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id
|
||||
|
||||
const shouldShowOpenInExploreOption =
|
||||
!props.app.has_draft_trigger &&
|
||||
(needsPublishBeforeExplore ||
|
||||
!systemFeatures.webapp_auth.enabled ||
|
||||
(!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)))
|
||||
|
||||
return (
|
||||
<AppCardOperationsMenu
|
||||
{...props}
|
||||
shouldShowOpenInExploreOption={shouldShowOpenInExploreOption}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -69,12 +69,10 @@ function CatalogSkeleton() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative grow content-start ${APP_LIST_GRID_CLASS_NAME}`}
|
||||
role="status"
|
||||
aria-label={t(($) => $.loading, { ns: 'common' })}
|
||||
>
|
||||
<AppCardSkeleton count={6} />
|
||||
<div className="relative grow" role="status" aria-label={t(($) => $.loading, { ns: 'common' })}>
|
||||
<ul aria-hidden className={APP_LIST_GRID_CLASS_NAME}>
|
||||
<AppCardSkeleton count={6} />
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -178,56 +176,68 @@ function AppListCatalogContent({
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
role={hasAnyApp ? 'list' : undefined}
|
||||
aria-busy={isFetching}
|
||||
aria-labelledby={hasAnyApp && starredApps.length > 0 ? ALL_APPS_HEADING_ID : undefined}
|
||||
className={cn(
|
||||
`relative grow content-start ${APP_LIST_GRID_CLASS_NAME}`,
|
||||
!hasAnyApp && 'overflow-hidden',
|
||||
)}
|
||||
className={cn('relative grow', !hasAnyApp && 'overflow-hidden')}
|
||||
>
|
||||
{hasAnyApp ? (
|
||||
apps.map((app, index) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id]}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
stepByStepTourActionMenuOpen={
|
||||
index === 0 ? shouldOpenFirstAppActionMenu : undefined
|
||||
}
|
||||
stepByStepTourCardTarget={
|
||||
index === 0
|
||||
? shouldHighlightAllAppsRow
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
|
||||
: canCreateApp
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
stepByStepTourCardHighlightPart={
|
||||
index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && shouldHighlightAllAppsRow
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard
|
||||
: undefined
|
||||
}
|
||||
stepByStepTourActionMenuHighlightPart={
|
||||
index === 0 && shouldOpenFirstAppActionMenu
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
|
||||
<ul
|
||||
// Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility
|
||||
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers.
|
||||
role="list"
|
||||
aria-label={
|
||||
starredApps.length === 0
|
||||
? t(($) => $['studio.allApps'], { ns: 'app' })
|
||||
: undefined
|
||||
}
|
||||
aria-labelledby={starredApps.length > 0 ? ALL_APPS_HEADING_ID : undefined}
|
||||
className={APP_LIST_GRID_CLASS_NAME}
|
||||
>
|
||||
{apps.map((app, index) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id]}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
stepByStepTourActionMenuOpen={
|
||||
index === 0 ? shouldOpenFirstAppActionMenu : undefined
|
||||
}
|
||||
stepByStepTourCardTarget={
|
||||
index === 0
|
||||
? shouldHighlightAllAppsRow
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
|
||||
: canCreateApp
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
stepByStepTourCardHighlightPart={
|
||||
index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && shouldHighlightAllAppsRow
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard
|
||||
: undefined
|
||||
}
|
||||
stepByStepTourActionMenuHighlightPart={
|
||||
index === 0 && shouldOpenFirstAppActionMenu
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && <AppCardSkeleton count={3} />}
|
||||
</ul>
|
||||
) : (
|
||||
<div className={`content-start ${APP_LIST_GRID_CLASS_NAME}`}>
|
||||
<Empty
|
||||
message={emptyMessage}
|
||||
stepByStepTourTarget={
|
||||
showNoCreateEmptyState
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Empty
|
||||
message={emptyMessage}
|
||||
stepByStepTourTarget={
|
||||
showNoCreateEmptyState ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasNextPage && (
|
||||
<>
|
||||
<AppCardSkeleton count={3} />
|
||||
{isFetchNextPageError && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex h-40 items-center justify-center gap-2 bg-background-body system-xs-regular text-text-tertiary"
|
||||
|
||||
@@ -17,7 +17,7 @@ import Link from '@/next/link'
|
||||
import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
|
||||
import { formatTime } from '@/utils/time'
|
||||
import { AppCardActionBar } from './app-card/action-bar'
|
||||
import { AppCardInteractions } from './app-card/interactions'
|
||||
|
||||
type StarredAppCardProps = {
|
||||
app: AppPartial
|
||||
@@ -55,10 +55,8 @@ export const StarredAppCard = memo(
|
||||
isRbacEnabled,
|
||||
})
|
||||
const cardClassName = cn(
|
||||
'flex h-18 min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg px-4 py-3 shadow-xs outline-hidden transition-shadow duration-200',
|
||||
isPreviewOnly
|
||||
? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
: 'hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'flex h-18 min-w-0 items-center gap-3 rounded-xl px-4 py-3 outline-hidden',
|
||||
isPreviewOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
|
||||
)
|
||||
const showPreviewOnlyAccessWarning = useCallback(() => {
|
||||
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
|
||||
@@ -95,7 +93,13 @@ export const StarredAppCard = memo(
|
||||
)
|
||||
|
||||
return (
|
||||
<div role="listitem" className="group relative">
|
||||
<li
|
||||
className={cn(
|
||||
"group relative isolate overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid has-[>button:focus-visible]:after:inset-ring-2 has-[>button:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none",
|
||||
!isPreviewOnly &&
|
||||
'hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 [@media(hover:none)]:bg-components-card-bg-alt',
|
||||
)}
|
||||
>
|
||||
{isPreviewOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -108,18 +112,19 @@ export const StarredAppCard = memo(
|
||||
{cardContent}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
aria-labelledby={appNameId}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={cardClassName}
|
||||
>
|
||||
{cardContent}
|
||||
</Link>
|
||||
<AppCardInteractions app={app}>
|
||||
<Link
|
||||
href={href}
|
||||
aria-labelledby={appNameId}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={cardClassName}
|
||||
>
|
||||
{cardContent}
|
||||
</Link>
|
||||
</AppCardInteractions>
|
||||
)}
|
||||
{!isPreviewOnly && <AppCardActionBar app={app} />}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -43,7 +43,9 @@ export function StarredAppList({
|
||||
id={STARRED_APPS_HEADING_ID}
|
||||
label={t(($) => $['studio.starred'], { ns: 'app' })}
|
||||
/>
|
||||
<div
|
||||
<ul
|
||||
// Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility
|
||||
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers.
|
||||
role="list"
|
||||
aria-labelledby={STARRED_APPS_HEADING_ID}
|
||||
className={APP_LIST_GRID_CLASS_NAME}
|
||||
@@ -60,7 +62,7 @@ export function StarredAppList({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,14 +113,14 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<EditAgentDialog
|
||||
key={editSessionKey}
|
||||
agent={dialogAgent}
|
||||
formKey={editSessionKey}
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
/>
|
||||
<DuplicateAgentDialog
|
||||
key={duplicateSessionKey}
|
||||
agent={dialogAgent}
|
||||
formKey={duplicateSessionKey}
|
||||
open={isDuplicateOpen}
|
||||
onOpenChange={setIsDuplicateOpen}
|
||||
/>
|
||||
|
||||
@@ -130,9 +130,11 @@ describe('AgentRosterList', () => {
|
||||
it('exposes each agent card with the agent name', () => {
|
||||
renderList([createAgent()])
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Research Agent' })
|
||||
const list = screen.getByRole('list')
|
||||
const card = within(list).getByRole('listitem', { name: 'Research Agent' })
|
||||
const cardLink = within(card).getByRole('link', { name: 'Research Agent' })
|
||||
|
||||
expect(card.parentElement).toBe(list)
|
||||
expect(cardLink).toHaveAttribute('href', '/agents/agent-1/configure')
|
||||
expect(cardLink).toHaveAccessibleDescription('Find and summarize market materials.')
|
||||
})
|
||||
@@ -222,7 +224,7 @@ describe('AgentRosterList', () => {
|
||||
footer: { status: 'error', onRetry: onLoadMore },
|
||||
})
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('listitem', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
@@ -234,7 +236,7 @@ describe('AgentRosterList', () => {
|
||||
const onRetry = vi.fn()
|
||||
renderList([createAgent()], { footer: { status: 'error', onRetry } })
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('listitem', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('agentV2.roster.loadingError')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
@@ -272,7 +274,7 @@ describe('AgentRosterList', () => {
|
||||
it('announces zero workflow references without exposing an inactive button', () => {
|
||||
renderList([createAgent()])
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Research Agent' })
|
||||
const card = screen.getByRole('listitem', { name: 'Research Agent' })
|
||||
expect(within(card).getByText(/^agentV2\.roster\.references\.trigger/)).toHaveClass('sr-only')
|
||||
expect(
|
||||
within(card).queryByRole('button', { name: /agentV2\.roster\.references\.trigger/ }),
|
||||
@@ -296,7 +298,7 @@ describe('AgentRosterList', () => {
|
||||
}),
|
||||
])
|
||||
|
||||
const card = screen.getByRole('article', { name: 'Research Agent' })
|
||||
const card = screen.getByRole('listitem', { name: 'Research Agent' })
|
||||
const cardLink = within(card).getByRole('link', { name: 'Research Agent' })
|
||||
const references = within(card).getByRole('button', {
|
||||
name: /agentV2\.roster\.references\.trigger.*1/,
|
||||
@@ -344,6 +346,33 @@ describe('AgentRosterList', () => {
|
||||
expect(duplicateAgentMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the same duplicate action from the card context menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderList([createAgent()])
|
||||
|
||||
const cardLink = screen.getByRole('link', { name: 'Research Agent' })
|
||||
await user.pointer({ target: cardLink, keys: '[MouseRight]' })
|
||||
await user.click(await screen.findByRole('menuitem', { name: /common\.operation\.duplicate/ }))
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the more button outside the card context menu trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderList([createAgent()])
|
||||
|
||||
await user.pointer({
|
||||
target: screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }),
|
||||
keys: '[MouseRight]',
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menuitem', { name: /common\.operation\.duplicate/ }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exports the Agent App DSL with the backing App id', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderList([createAgent()])
|
||||
@@ -544,6 +573,7 @@ describe('AgentRosterList', () => {
|
||||
screen.queryByRole('dialog', { name: 'agentV2.roster.editDialog.title' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })).toHaveFocus()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }))
|
||||
await user.click(screen.getByRole('menuitem', { name: /agentV2\.roster\.editInfo/ }))
|
||||
|
||||
@@ -71,7 +71,7 @@ const createAgent = (overrides: Partial<AgentAppPartial> = {}): AgentAppPartial
|
||||
const renderDialog = (agent = createAgent()) => {
|
||||
const onOpenChange = vi.fn()
|
||||
|
||||
render(<EditAgentDialog agent={agent} formKey={0} open onOpenChange={onOpenChange} />)
|
||||
render(<EditAgentDialog agent={agent} open onOpenChange={onOpenChange} />)
|
||||
|
||||
return { onOpenChange }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.ge
|
||||
import { zAgentIconType } from '@dify/contracts/api/console/agent/zod.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@langgenius/dify-ui/context-menu'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -105,9 +112,9 @@ function AgentRosterPlaceholderState({
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<section
|
||||
<div
|
||||
aria-labelledby="agent-roster-placeholder-title"
|
||||
className="relative col-span-full min-h-[calc(100vh-142px)] overflow-hidden"
|
||||
className="relative min-h-[calc(100vh-142px)] overflow-hidden"
|
||||
role={role}
|
||||
>
|
||||
<div
|
||||
@@ -141,22 +148,65 @@ function AgentRosterPlaceholderState({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AgentCardActionMenuItemsProps = {
|
||||
kind: 'context' | 'dropdown'
|
||||
isExporting: boolean
|
||||
onDelete: () => void
|
||||
onDuplicate: () => void
|
||||
onEdit: () => void
|
||||
onExport: () => void
|
||||
}
|
||||
|
||||
function AgentCardActionMenuItems({
|
||||
kind,
|
||||
isExporting,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onEdit,
|
||||
onExport,
|
||||
}: AgentCardActionMenuItemsProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tApp } = useTranslation('app')
|
||||
const MenuItem = kind === 'context' ? ContextMenuItem : DropdownMenuItem
|
||||
const MenuSeparator = kind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem className="gap-2" onClick={onEdit}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t(($) => $['roster.editInfo'])}</span>
|
||||
</MenuItem>
|
||||
<MenuItem className="gap-2" onClick={onDuplicate}>
|
||||
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{tCommon(($) => $['operation.duplicate'])}</span>
|
||||
</MenuItem>
|
||||
<MenuItem className="gap-2" disabled={isExporting} onClick={onExport}>
|
||||
<span aria-hidden className="i-ri-download-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{tApp(($) => $.export)}</span>
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem variant="destructive" className="gap-2" onClick={onDelete}>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{tCommon(($) => $['operation.delete'])}</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const { t: tApp } = useTranslation('app')
|
||||
const { formatTime } = useTimestamp()
|
||||
const nameId = useId()
|
||||
const descriptionId = useId()
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const [activeDialog, setActiveDialog] = useState<'delete' | 'duplicate' | 'edit' | null>(null)
|
||||
const [editSessionKey, setEditSessionKey] = useState(0)
|
||||
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false)
|
||||
const [duplicateSessionKey, setDuplicateSessionKey] = useState(0)
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
||||
const { exportAppDsl, isExporting } = useExportAppDsl()
|
||||
const updatedAt =
|
||||
agent.updated_at != null
|
||||
@@ -175,12 +225,20 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
|
||||
const handleEditOpen = () => {
|
||||
setEditSessionKey((key) => key + 1)
|
||||
setIsEditOpen(true)
|
||||
setActiveDialog('edit')
|
||||
}
|
||||
|
||||
const handleDuplicateOpen = () => {
|
||||
setDuplicateSessionKey((key) => key + 1)
|
||||
setIsDuplicateOpen(true)
|
||||
setActiveDialog('duplicate')
|
||||
}
|
||||
|
||||
const handleDeleteOpen = () => {
|
||||
setActiveDialog('delete')
|
||||
}
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
if (!open) setActiveDialog(null)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
@@ -196,49 +254,64 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
<li
|
||||
aria-labelledby={nameId}
|
||||
className="group relative isolate col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none [@media(hover:none)]:bg-components-card-bg-alt"
|
||||
className="group relative isolate col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-xl after:content-[''] focus-within:bg-components-card-bg-alt hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5 has-data-popup-open:bg-components-card-bg-alt has-data-popup-open:shadow-md has-data-popup-open:shadow-shadow-shadow-5 has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid motion-reduce:transition-none [@media(hover:none)]:bg-components-card-bg-alt"
|
||||
>
|
||||
<Link
|
||||
href={`/agents/${agent.id}/configure`}
|
||||
aria-labelledby={nameId}
|
||||
aria-describedby={agent.description ? descriptionId : undefined}
|
||||
className="flex h-full min-w-0 cursor-pointer touch-manipulation flex-col rounded-xl outline-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
|
||||
<span aria-hidden className="shrink-0">
|
||||
<AppIcon
|
||||
size="xl"
|
||||
rounded
|
||||
iconType={iconType}
|
||||
icon={agent.icon ?? undefined}
|
||||
background={agent.icon_background}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
|
||||
<h2 id={nameId} className="truncate system-md-semibold text-text-secondary">
|
||||
{agent.name}
|
||||
</h2>
|
||||
<p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
|
||||
<div id={descriptionId} className="line-clamp-2 min-h-8">
|
||||
{agent.description}
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden className="h-9 shrink-0" />
|
||||
{isDraft && (
|
||||
<div className="pointer-events-none absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
|
||||
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
|
||||
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['roster.usageStatus.draft'])}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
<Link
|
||||
href={`/agents/${agent.id}/configure`}
|
||||
aria-labelledby={nameId}
|
||||
aria-describedby={agent.description ? descriptionId : undefined}
|
||||
className="flex h-full min-w-0 cursor-pointer touch-manipulation flex-col rounded-xl pb-9 outline-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3 pt-3.5 pr-4 pb-2 pl-3.5">
|
||||
<span aria-hidden className="shrink-0">
|
||||
<AppIcon
|
||||
size="xl"
|
||||
rounded
|
||||
iconType={iconType}
|
||||
icon={agent.icon ?? undefined}
|
||||
background={agent.icon_background}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 py-px">
|
||||
<h2 id={nameId} className="truncate system-md-semibold text-text-secondary">
|
||||
{agent.name}
|
||||
</h2>
|
||||
<p className="truncate system-xs-regular text-text-tertiary">{agent.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-1 system-xs-regular text-text-tertiary">
|
||||
<div id={descriptionId} className="line-clamp-2 min-h-8">
|
||||
{agent.description}
|
||||
</div>
|
||||
</div>
|
||||
{isDraft && (
|
||||
<div className="pointer-events-none absolute top-[-0.5px] right-0 flex h-5 items-start overflow-hidden">
|
||||
<div className="h-5 w-3 bg-background-section-burn [clip-path:polygon(0_0,100%_0,100%_100%)]" />
|
||||
<div className="flex h-5 items-center bg-background-section-burn pr-2 pl-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['roster.usageStatus.draft'])}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<ContextMenuContent className="w-40">
|
||||
<AgentCardActionMenuItems
|
||||
kind="context"
|
||||
isExporting={isExporting}
|
||||
onEdit={handleEditOpen}
|
||||
onDuplicate={handleDuplicateOpen}
|
||||
onExport={handleExport}
|
||||
onDelete={handleDeleteOpen}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<div className="pointer-events-none absolute top-[-0.5px] right-[-0.5px] flex h-16 w-30 items-start justify-end bg-[linear-gradient(67deg,var(--color-components-card-bg-alt-transparent)_0%,var(--color-components-card-bg-alt)_75%)] p-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 has-data-popup-open:opacity-100 [@media(hover:none)]:opacity-100">
|
||||
<div className="pointer-events-none flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs group-focus-within:pointer-events-auto group-hover:pointer-events-auto has-data-popup-open:pointer-events-auto [@media(hover:none)]:pointer-events-auto">
|
||||
<DropdownMenu modal={false}>
|
||||
@@ -254,33 +327,14 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} className="w-40">
|
||||
<DropdownMenuItem className="gap-2" onClick={handleEditOpen}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t(($) => $['roster.editInfo'])}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" onClick={handleDuplicateOpen}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span>{tCommon(($) => $['operation.duplicate'])}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" disabled={isExporting} onClick={handleExport}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-download-line size-4 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<span>{tApp(($) => $.export)}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span>{tCommon(($) => $['operation.delete'])}</span>
|
||||
</DropdownMenuItem>
|
||||
<AgentCardActionMenuItems
|
||||
kind="dropdown"
|
||||
isExporting={isExporting}
|
||||
onEdit={handleEditOpen}
|
||||
onDuplicate={handleDuplicateOpen}
|
||||
onExport={handleExport}
|
||||
onDelete={handleDeleteOpen}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -316,24 +370,24 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
</div>
|
||||
</div>
|
||||
<EditAgentDialog
|
||||
key={editSessionKey}
|
||||
agent={agent}
|
||||
formKey={editSessionKey}
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
open={activeDialog === 'edit'}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
/>
|
||||
<DuplicateAgentDialog
|
||||
key={duplicateSessionKey}
|
||||
agent={agent}
|
||||
formKey={duplicateSessionKey}
|
||||
open={isDuplicateOpen}
|
||||
onOpenChange={setIsDuplicateOpen}
|
||||
open={activeDialog === 'duplicate'}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
/>
|
||||
<DeleteAgentDialog
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
open={activeDialog === 'delete'}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
/>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,8 +397,12 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
|
||||
const isBusy = state.status === 'pending' || (state.status === 'ready' && state.isFetching)
|
||||
|
||||
return (
|
||||
<section aria-label={label} className={AGENT_ROSTER_GRID_CLASS_NAME} aria-busy={isBusy}>
|
||||
{state.status === 'pending' && <AgentRosterSkeleton />}
|
||||
<section aria-label={label} aria-busy={isBusy}>
|
||||
{state.status === 'pending' && (
|
||||
<div className={AGENT_ROSTER_GRID_CLASS_NAME}>
|
||||
<AgentRosterSkeleton />
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'error' && (
|
||||
<AgentRosterPlaceholderState
|
||||
onRetry={state.onRetry}
|
||||
@@ -363,10 +421,18 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
|
||||
/>
|
||||
)}
|
||||
{state.status === 'ready' &&
|
||||
state.agents.map((agent) => <AgentRosterItem key={agent.id} agent={agent} />)}
|
||||
state.agents.length > 0 && (
|
||||
// Safari list semantics: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/list-style#accessibility
|
||||
// oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Dify's preflight removes list markers.
|
||||
<ul role="list" className={AGENT_ROSTER_GRID_CLASS_NAME}>
|
||||
{state.agents.map((agent) => (
|
||||
<AgentRosterItem key={agent.id} agent={agent} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{state.status === 'ready' && state.footer.status === 'error' && (
|
||||
<div
|
||||
className="col-span-full flex items-center justify-center gap-3 pt-1 system-xs-regular text-text-destructive"
|
||||
className="flex items-center justify-center gap-3 pt-1 system-xs-regular text-text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<span>{t(($) => $['roster.loadingError'])}</span>
|
||||
@@ -376,7 +442,7 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'ready' && state.footer.status === 'load-more' && (
|
||||
<div className="col-span-full flex justify-center pt-1">
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button loading={state.footer.isLoading} onClick={state.footer.onLoadMore}>
|
||||
{t(($) => $['roster.loadMore'])}
|
||||
</Button>
|
||||
|
||||
@@ -28,7 +28,6 @@ import { createAgentIconSelection } from './agent-form'
|
||||
|
||||
type DuplicateAgentDialogProps = {
|
||||
agent: AgentAppPartial
|
||||
formKey: number
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
@@ -38,12 +37,7 @@ const getDefaultCopyName = (name: string) => {
|
||||
return `${name.slice(0, 255 - suffix.length)}${suffix}`
|
||||
}
|
||||
|
||||
export function DuplicateAgentDialog({
|
||||
agent,
|
||||
formKey,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DuplicateAgentDialogProps) {
|
||||
export function DuplicateAgentDialog({ agent, open, onOpenChange }: DuplicateAgentDialogProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
@@ -57,7 +51,6 @@ export function DuplicateAgentDialog({
|
||||
},
|
||||
}),
|
||||
) ?? agent
|
||||
const [renderedFormKey, setRenderedFormKey] = useState(formKey)
|
||||
const [name, setName] = useState(() => getDefaultCopyName(latestAgent.name))
|
||||
const [description, setDescription] = useState(latestAgent.description ?? '')
|
||||
const [role, setRole] = useState(latestAgent.role ?? '')
|
||||
@@ -68,34 +61,8 @@ export function DuplicateAgentDialog({
|
||||
const duplicateAgentMutation = useMutation(
|
||||
consoleQuery.agent.byAgentId.copy.post.mutationOptions(),
|
||||
)
|
||||
if (formKey !== renderedFormKey) {
|
||||
setRenderedFormKey(formKey)
|
||||
setName(getDefaultCopyName(latestAgent.name))
|
||||
setDescription(latestAgent.description ?? '')
|
||||
setRole(latestAgent.role ?? '')
|
||||
setIconPickerOpen(false)
|
||||
setAgentIcon(createAgentIconSelection(latestAgent))
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
const currentAgent =
|
||||
queryClient.getQueryData<AgentAppPartial>(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agent.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
) ?? agent
|
||||
setName(getDefaultCopyName(currentAgent.name))
|
||||
setDescription(currentAgent.description ?? '')
|
||||
setRole(currentAgent.role ?? '')
|
||||
setAgentIcon(createAgentIconSelection(currentAgent))
|
||||
} else {
|
||||
setIconPickerOpen(false)
|
||||
}
|
||||
if (!nextOpen) setIconPickerOpen(false)
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
@@ -152,11 +119,7 @@ export function DuplicateAgentDialog({
|
||||
{t(($) => $['roster.duplicateDialog.description'], { name: latestAgent.name })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Form<AgentFormValues>
|
||||
key={formKey}
|
||||
className="min-h-0 flex-1"
|
||||
onFormSubmit={handleSubmit}
|
||||
>
|
||||
<Form<AgentFormValues> className="min-h-0 flex-1" onFormSubmit={handleSubmit}>
|
||||
<div className="space-y-5 px-6 py-3">
|
||||
<div className="flex items-end gap-4 pb-2">
|
||||
<button
|
||||
|
||||
@@ -25,7 +25,6 @@ import { AgentFormFields } from './agent-form-fields'
|
||||
|
||||
type EditAgentDialogProps = {
|
||||
agent: AgentAppPartial
|
||||
formKey: number
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
@@ -43,10 +42,9 @@ const applyIconPayload = (body: AgentAppUpdatePayload, icon: AgentIconSelection)
|
||||
body.icon_background = undefined
|
||||
}
|
||||
|
||||
export function EditAgentDialog({ agent, formKey, open, onOpenChange }: EditAgentDialogProps) {
|
||||
export function EditAgentDialog({ agent, open, onOpenChange }: EditAgentDialogProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [renderedFormKey, setRenderedFormKey] = useState(formKey)
|
||||
const [name, setName] = useState(agent.name)
|
||||
const [description, setDescription] = useState(agent.description ?? '')
|
||||
const [role, setRole] = useState(agent.role ?? '')
|
||||
@@ -56,24 +54,8 @@ export function EditAgentDialog({ agent, formKey, open, onOpenChange }: EditAgen
|
||||
)
|
||||
const updateAgentMutation = useMutation(consoleQuery.agent.byAgentId.put.mutationOptions())
|
||||
|
||||
if (formKey !== renderedFormKey) {
|
||||
setRenderedFormKey(formKey)
|
||||
setName(agent.name)
|
||||
setDescription(agent.description ?? '')
|
||||
setRole(agent.role ?? '')
|
||||
setIconPickerOpen(false)
|
||||
setAgentIcon(createAgentIconSelection(agent))
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setName(agent.name)
|
||||
setDescription(agent.description ?? '')
|
||||
setRole(agent.role ?? '')
|
||||
setAgentIcon(createAgentIconSelection(agent))
|
||||
} else {
|
||||
setIconPickerOpen(false)
|
||||
}
|
||||
if (!nextOpen) setIconPickerOpen(false)
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
@@ -153,11 +135,7 @@ export function EditAgentDialog({ agent, formKey, open, onOpenChange }: EditAgen
|
||||
{t(($) => $['roster.editDialog.description'])}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Form<AgentFormValues>
|
||||
key={formKey}
|
||||
className="min-h-0 flex-1"
|
||||
onFormSubmit={handleSubmit}
|
||||
>
|
||||
<Form<AgentFormValues> className="min-h-0 flex-1" onFormSubmit={handleSubmit}>
|
||||
<AgentFormFields
|
||||
description={description}
|
||||
icon={agentIcon}
|
||||
|
||||
Reference in New Issue
Block a user