refactor(tests): simplify App Card operations flow tests by extracting common menu opening logic

This commit is contained in:
CodingOnStar
2026-04-16 09:25:10 +08:00
parent ae1e311167
commit 24598c9374
5 changed files with 102 additions and 122 deletions

View File

@ -258,6 +258,10 @@ const renderAppCard = (app?: Partial<App>) => {
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
}
const openOperationsMenu = () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
}
describe('App Card Operations Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -313,32 +317,19 @@ describe('App Card Operations Flow', () => {
it('should show delete confirmation and call API on confirm', async () => {
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
// Find and click the more button (popover trigger)
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
fireEvent.click(await screen.findByText('common.operation.delete'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
})
await waitFor(() => {
const deleteBtn = screen.queryByText('common.operation.delete')
if (deleteBtn)
fireEvent.click(deleteBtn)
})
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
})
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
})
}
await waitFor(() => {
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
})
})
})
@ -347,34 +338,18 @@ describe('App Card Operations Flow', () => {
it('should open edit modal and call updateAppInfo on confirm', async () => {
renderAppCard({ id: 'app-edit', name: 'Editable App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
fireEvent.click(await screen.findByText('app.editApp'))
fireEvent.click(await screen.findByTestId('confirm-edit'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
const editBtn = screen.queryByText('app.editApp')
if (editBtn)
fireEvent.click(editBtn)
})
const confirmEdit = screen.queryByTestId('confirm-edit')
if (confirmEdit) {
fireEvent.click(confirmEdit)
await waitFor(() => {
expect(updateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
appID: 'app-edit',
name: 'Updated App Name',
}),
)
})
}
}
await waitFor(() => {
expect(updateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
appID: 'app-edit',
name: 'Updated App Name',
}),
)
})
})
})
@ -383,26 +358,14 @@ describe('App Card Operations Flow', () => {
it('should call exportAppConfig for completion apps', async () => {
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
fireEvent.click(await screen.findByText('app.export'))
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
const exportBtn = screen.queryByText('app.export')
if (exportBtn)
fireEvent.click(exportBtn)
})
await waitFor(() => {
expect(exportAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ appID: 'app-export' }),
)
})
}
await waitFor(() => {
expect(exportAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ appID: 'app-export' }),
)
})
})
})
@ -422,35 +385,21 @@ describe('App Card Operations Flow', () => {
it('should show switch option for chat mode apps', async () => {
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.queryByText('app.switch')).toBeInTheDocument()
})
}
await waitFor(() => {
expect(screen.queryByText('app.switch')).toBeInTheDocument()
})
})
it('should not show switch option for workflow apps', async () => {
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
const moreIcons = document.querySelectorAll('svg')
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
openOperationsMenu()
if (moreFill) {
const btn = moreFill.closest('[class*="cursor-pointer"]')
if (btn)
fireEvent.click(btn)
await waitFor(() => {
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
}
await waitFor(() => {
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
})
})
})
})

View File

@ -176,6 +176,29 @@ const AppCardOperationsMenu: React.FC<AppCardOperationsMenuProps> = ({
)
}
type AppCardOperationsMenuContentProps = Omit<AppCardOperationsMenuProps, 'shouldShowOpenInExploreOption'>
const AppCardOperationsMenuContent: React.FC<AppCardOperationsMenuContentProps> = (props) => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: props.app.id,
enabled: systemFeatures.webapp_auth.enabled,
})
const shouldShowOpenInExploreOption = !props.app.has_draft_trigger
&& (
!systemFeatures.webapp_auth.enabled
|| (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))
)
return (
<AppCardOperationsMenu
{...props}
shouldShowOpenInExploreOption={shouldShowOpenInExploreOption}
/>
)
}
const AppCard = ({ app, onRefresh }: AppCardProps) => {
const { t } = useTranslation()
const deleteAppNameInputId = useId()
@ -192,10 +215,6 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
const [showAccessControl, setShowAccessControl] = useState(false)
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({
appId: app.id,
enabled: isOperationsMenuOpen && systemFeatures.webapp_auth.enabled,
})
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
const onConfirmDelete = useCallback(async () => {
@ -365,11 +384,6 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
}, [onRefresh, setShowAccessControl])
const shouldShowSwitchOption = app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT
const shouldShowOpenInExploreOption = !app.has_draft_trigger
&& (
!systemFeatures.webapp_auth.enabled
|| (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))
)
const shouldShowAccessControlOption = systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor
const operationsMenuWidthClassName = shouldShowSwitchOption ? 'w-[256px]' : 'w-[216px]'
@ -495,24 +509,42 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={shouldShowOpenInExploreOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
</DropdownMenuContent>
{isOperationsMenuOpen && (
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (
<AppCardOperationsMenuContent
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)
: (
<AppCardOperationsMenu
app={app}
shouldShowSwitchOption={shouldShowSwitchOption}
shouldShowOpenInExploreOption={!app.has_draft_trigger}
shouldShowAccessControlOption={shouldShowAccessControlOption}
onEdit={handleShowEditModal}
onDuplicate={handleShowDuplicateModal}
onExport={exportCheck}
onSwitch={handleShowSwitchModal}
onDelete={handleShowDeleteConfirm}
onAccessControl={handleShowAccessControl}
/>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
</div>
</>

View File

@ -1,3 +1,4 @@
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/app/components/base/ui/button'
@ -6,7 +7,6 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
import Operations from './operations'
type ActionsProps = {

View File

@ -370,8 +370,7 @@ describe('DocumentList', () => {
})
}
// After clicking rename, the modal should potentially be visible
expect(screen.getByRole('table')).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'datasetDocuments.list.table.rename' })).toBeInTheDocument()
})
it('should call onUpdate when document is renamed', () => {

View File

@ -1,5 +1,5 @@
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type OperationItemProps = {
iconClassName: string