diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 84a880c6804..46a56fb684e 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -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() - 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() - 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() @@ -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() + + 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() + + 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() @@ -1203,6 +1226,7 @@ describe('AppCard', () => { render() const trigger = screen.getByRole('button', { name: 'common.operation.exporting' }) + expect(trigger).toBeDisabled() }) }) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 9ef5a5cdd8b..f1cf813ea31 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -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', () => { diff --git a/web/app/components/apps/app-card-skeleton.tsx b/web/app/components/apps/app-card-skeleton.tsx index 977ff7a3407..c60429d914c 100644 --- a/web/app/components/apps/app-card-skeleton.tsx +++ b/web/app/components/apps/app-card-skeleton.tsx @@ -17,7 +17,8 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) return ( <> {skeletonKeys.map((key) => ( -
@@ -34,7 +35,7 @@ export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps)
- + ))} ) diff --git a/web/app/components/apps/app-card/action-bar/index.tsx b/web/app/components/apps/app-card/action-bar/index.tsx deleted file mode 100644 index 5d46c6b36b1..00000000000 --- a/web/app/components/apps/app-card/action-bar/index.tsx +++ /dev/null @@ -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(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([]) - 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 = 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 && ( -
- - - - - } - /> - } - /> - {starToggleLabel} - - {shouldShowOperationsMenu && ( - - $['operation.exporting'], { ns: 'common' }) - : t(($) => $['operation.moreActionsFor'], { - ns: 'common', - name: app.name, - }) - } - disabled={isExporting} - className="data-popup-open:bg-state-base-hover" - > - - - } - /> - - - - - )} -
- )} - {showEditModal && ( - setShowEditModal(false)} - /> - )} - {showDuplicateModal && ( - setShowDuplicateModal(false)} - /> - )} - {showSwitchModal && ( - setShowSwitchModal(false)} - /> - )} - - -
-
- - {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} - - - {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} - - - - $.deleteAppConfirmInputLabel} - ns="app" - values={{ appName: app.name }} - components={{ - appName: ( - - ), - }} - /> - - - $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} - value={confirmDeleteInput} - onValueChange={setConfirmDeleteInput} - /> - - - - - -
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - - {t(($) => $['operation.confirm'], { ns: 'common' })} - - -
-
-
- {secretEnvList.length > 0 && ( - setSecretEnvList([])} - /> - )} - - ) - }, -) diff --git a/web/app/components/apps/app-card/index.tsx b/web/app/components/apps/app-card/index.tsx index d31bf1df410..0d790f2dd9d 100644 --- a/web/app/components/apps/app-card/index.tsx +++ b/web/app/components/apps/app-card/index.tsx @@ -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} -
-
+
{app.author_name && ( <> @@ -188,7 +185,13 @@ export const AppCard = memo( ) return ( -
+
  • 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 ? (
  • + ) }, ) diff --git a/web/app/components/apps/app-card/interactions.tsx b/web/app/components/apps/app-card/interactions.tsx new file mode 100644 index 00000000000..137e04c0bdb --- /dev/null +++ b/web/app/components/apps/app-card/interactions.tsx @@ -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([ + 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, action: () => void) { + event.stopPropagation() + event.preventDefault() + action() + } + + async function handleOpenInstalledApp(event: MouseEvent) { + 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 && ( + handleMenuAction(event, onEdit)}> + + {t(($) => $.editApp, { ns: 'app' })} + + + )} + {hasEditGroup && + (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( + + )} + {shouldShowDuplicateOption && ( + handleMenuAction(event, onDuplicate)}> + + {t(($) => $.duplicate, { ns: 'app' })} + + + )} + {shouldShowExportOption && ( + handleMenuAction(event, onExport)} + > + + {t(($) => $.export, { ns: 'app' })} + + + )} + {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( + + )} + {shouldShowSwitchOption && ( + handleMenuAction(event, onSwitch)}> + {t(($) => $.switch, { ns: 'app' })} + + )} + {shouldShowOpenInExploreOption && ( + + + {t(($) => $.openInExplore, { ns: 'app' })} + + + )} + {hasSwitchOrExploreGroup && hasAccessDeleteGroup && } + {shouldShowAccessConfigOption && ( + handleMenuAction(event, onAccessConfig)} + > + + {t(($) => $['settings.resourceAccess'], { ns: 'common' })} + + + )} + {shouldShowAccessConfigOption && shouldShowDeleteOption && } + {shouldShowDeleteOption && ( + handleMenuAction(event, onDelete)} + > + + {t(($) => $['operation.delete'], { ns: 'common' })} + + + )} + + ) +} + +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([]) + 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 = 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 ? ( + + + + + + + ) : ( + children + )} +
    +
    + + + + + } + /> + } + /> + {starToggleLabel} + + {shouldShowOperationsMenu && ( + + $['operation.exporting'], { ns: 'common' }) + : t(($) => $['operation.moreActionsFor'], { + ns: 'common', + name: app.name, + }) + } + disabled={isExporting} + className="data-popup-open:bg-state-base-hover" + > + + + } + /> + + + + + )} +
    +
    + {activeDialog === 'edit' && ( + setActiveDialog(null)} + /> + )} + {activeDialog === 'duplicate' && ( + setActiveDialog(null)} + /> + )} + {activeDialog === 'switch' && ( + setActiveDialog(null)} /> + )} + + +
    +
    + + {t(($) => $.deleteAppConfirmTitle, { ns: 'app' })} + + + {t(($) => $.deleteAppConfirmContent, { ns: 'app' })} + + + + $.deleteAppConfirmInputLabel} + ns="app" + values={{ appName: app.name }} + components={{ + appName: ( + + ), + }} + /> + + + $.deleteAppConfirmInputPlaceholder, { ns: 'app' })} + value={confirmDeleteInput} + onValueChange={setConfirmDeleteInput} + /> + + + + + +
    + + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + + {t(($) => $['operation.confirm'], { ns: 'common' })} + + +
    +
    +
    + {secretEnvList.length > 0 && ( + setSecretEnvList([])} + /> + )} + + ) +} diff --git a/web/app/components/apps/app-card/operations-menu.tsx b/web/app/components/apps/app-card/operations-menu.tsx deleted file mode 100644 index 714d9dce82e..00000000000 --- a/web/app/components/apps/app-card/operations-menu.tsx +++ /dev/null @@ -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([ - 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, action: () => void) { - e.stopPropagation() - e.preventDefault() - action() - } - - async function handleOpenInstalledApp(e: MouseEvent) { - 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 && ( - handleMenuAction(e, onEdit)}> - - {t(($) => $.editApp, { ns: 'app' })} - - - )} - {hasEditGroup && - (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( - - )} - {shouldShowDuplicateOption && ( - handleMenuAction(e, onDuplicate)}> - - {t(($) => $.duplicate, { ns: 'app' })} - - - )} - {shouldShowExportOption && ( - handleMenuAction(e, onExport)} - > - - {t(($) => $.export, { ns: 'app' })} - - - )} - {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( - - )} - {shouldShowSwitchOption && ( - handleMenuAction(e, onSwitch)}> - {t(($) => $.switch, { ns: 'app' })} - - )} - {shouldShowOpenInExploreOption && ( - - - {t(($) => $.openInExplore, { ns: 'app' })} - - - )} - {hasSwitchOrExploreGroup && hasAccessDeleteGroup && } - {shouldShowAccessConfigOption && ( - handleMenuAction(e, onAccessConfig)} - > - - {t(($) => $['settings.resourceAccess'], { ns: 'common' })} - - - )} - {shouldShowAccessConfigOption && shouldShowDeleteOption && } - {shouldShowDeleteOption && ( - handleMenuAction(e, onDelete)} - > - - {t(($) => $['operation.delete'], { ns: 'common' })} - - - )} - - ) -} - -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 ( - - ) -} diff --git a/web/app/components/apps/app-list-catalog.tsx b/web/app/components/apps/app-list-catalog.tsx index 307d317819b..1330fe8d3b1 100644 --- a/web/app/components/apps/app-list-catalog.tsx +++ b/web/app/components/apps/app-list-catalog.tsx @@ -69,12 +69,10 @@ function CatalogSkeleton() { const { t } = useTranslation() return ( -
    $.loading, { ns: 'common' })} - > - +
    $.loading, { ns: 'common' })}> +
      + +
    ) } @@ -178,56 +176,68 @@ function AppListCatalogContent({ /> )}
    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) => ( - $['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) => ( + + ))} + {hasNextPage && } + + ) : ( +
    + - )) - ) : ( - +
    )} {hasNextPage && ( <> - {isFetchNextPageError && (
    { toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) @@ -95,7 +93,13 @@ export const StarredAppCard = memo( ) return ( -
    +
  • 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 ? (
  • + ) }, ) diff --git a/web/app/components/apps/starred-app-list.tsx b/web/app/components/apps/starred-app-list.tsx index 1486fd12eab..9d1a1e41695 100644 --- a/web/app/components/apps/starred-app-list.tsx +++ b/web/app/components/apps/starred-app-list.tsx @@ -43,7 +43,9 @@ export function StarredAppList({ id={STARRED_APPS_HEADING_ID} label={t(($) => $['studio.starred'], { ns: 'app' })} /> -
    ))} -
    + ) } diff --git a/web/features/agent-v2/agent-detail/sidebar-actions.tsx b/web/features/agent-v2/agent-detail/sidebar-actions.tsx index fd60792dc1c..03385bfda6e 100644 --- a/web/features/agent-v2/agent-detail/sidebar-actions.tsx +++ b/web/features/agent-v2/agent-detail/sidebar-actions.tsx @@ -113,14 +113,14 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx index 538e8008949..ad0bdf8d1cb 100644 --- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx @@ -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/ })) diff --git a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx index 9087b190def..8cee962675c 100644 --- a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx @@ -71,7 +71,7 @@ const createAgent = (overrides: Partial = {}): AgentAppPartial const renderDialog = (agent = createAgent()) => { const onOpenChange = vi.fn() - render() + render() return { onOpenChange } } diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index 49b73cd8c2a..64184d7b599 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -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 ( -
    -
    +
    + ) +} + +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 ( + <> + + + {t(($) => $['roster.editInfo'])} + + + + {tCommon(($) => $['operation.duplicate'])} + + + + {tApp(($) => $.export)} + + + + + {tCommon(($) => $['operation.delete'])} + + ) } 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 ( -
    - -
    - - - -
    -

    - {agent.name} -

    -

    {agent.role}

    -
    -
    -
    -
    - {agent.description} -
    -
    -
    - {isDraft && ( -
    -
    -
    - {t(($) => $['roster.usageStatus.draft'])} -
    -
    - )} - + + +
    + + + +
    +

    + {agent.name} +

    +

    {agent.role}

    +
    +
    +
    +
    + {agent.description} +
    +
    + {isDraft && ( +
    +
    +
    + {t(($) => $['roster.usageStatus.draft'])} +
    +
    + )} + + } + /> + + + +
    @@ -254,33 +327,14 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { } /> - - - {t(($) => $['roster.editInfo'])} - - - - {tCommon(($) => $['operation.duplicate'])} - - - - {tApp(($) => $.export)} - - - setIsDeleteOpen(true)} - > - - {tCommon(($) => $['operation.delete'])} - +
    @@ -316,24 +370,24 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
    -
    + ) } @@ -343,8 +397,12 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) { const isBusy = state.status === 'pending' || (state.status === 'ready' && state.isFetching) return ( -
    - {state.status === 'pending' && } +
    + {state.status === 'pending' && ( +
    + +
    + )} {state.status === 'error' && ( )} {state.status === 'ready' && - state.agents.map((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. +
      + {state.agents.map((agent) => ( + + ))} +
    + )} {state.status === 'ready' && state.footer.status === 'error' && (
    {t(($) => $['roster.loadingError'])} @@ -376,7 +442,7 @@ export function AgentRosterList({ label, state }: AgentRosterListProps) {
    )} {state.status === 'ready' && state.footer.status === 'load-more' && ( -
    +
    diff --git a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx index dc4e8b84b2e..779adcd1fd1 100644 --- a/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/duplicate-agent-dialog.tsx @@ -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( - 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 })}
    - - key={formKey} - className="min-h-0 flex-1" - onFormSubmit={handleSubmit} - > + className="min-h-0 flex-1" onFormSubmit={handleSubmit}>
    - - key={formKey} - className="min-h-0 flex-1" - onFormSubmit={handleSubmit} - > + className="min-h-0 flex-1" onFormSubmit={handleSubmit}>