diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index cf7ca7e19bb..4b88d0de95b 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -215,7 +215,7 @@ describe('App Publisher Flow', () => { fireEvent.click(screen.getByText('common.openInExplore')) await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith('No app found in Explore') + expect(mockToastError).toHaveBeenCalledWith('notPublishedYet') }) }) }) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index b4593347780..5bbe67b6890 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -562,7 +562,7 @@ describe('AppPublisher', () => { fireEvent.click(screen.getByText('publisher-open-in-explore')) await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith('No app found in Explore') + expect(mockToastError).toHaveBeenCalledWith('notPublishedYet') }) }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 2f35ae3f45a..01471186194 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -231,7 +231,7 @@ export function AppPublisher({ const { installed_apps } = await fetchInstalledAppList(appDetail.id) if (installed_apps?.length > 0) return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` - throw new Error('No app found in Explore') + throw new Error(t('notPublishedYet', { ns: 'app' })) }, { onError: (err) => { toast.error(`${err.message || err}`) diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 55aa304316e..3be6fa998d9 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -14,6 +14,10 @@ import { StarredAppCard } from '../starred-app-card' let mockWebappAuthEnabled = false let mockRbacEnabled = true +const mockUserCanAccessApp = vi.hoisted(() => ({ + result: true as boolean | undefined, + isLoading: false, +})) const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { systemFeatures: { @@ -118,15 +122,15 @@ vi.mock('@/service/explore', () => ({ vi.mock('@/service/access-control', () => ({ useGetUserCanAccessApp: () => ({ - data: { result: true }, - isLoading: false, + data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result }, + isLoading: mockUserCanAccessApp.isLoading, }), })) vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: () => ({ - data: { result: true }, - isLoading: false, + data: mockUserCanAccessApp.result === undefined ? undefined : { result: mockUserCanAccessApp.result }, + isLoading: mockUserCanAccessApp.isLoading, }), })) @@ -380,6 +384,8 @@ describe('AppCard', () => { mockOpenAsyncWindow.mockReset() mockWebappAuthEnabled = false mockRbacEnabled = true + mockUserCanAccessApp.result = true + mockUserCanAccessApp.isLoading = false mockDeleteMutationPending = false mockToggleStarMutationPending = false mockAppContext.isCurrentWorkspaceEditor = true @@ -1733,6 +1739,28 @@ describe('AppCard', () => { }) describe('Open in Explore - No App Found', () => { + it('should tell workflow users to publish before opening in explore', async () => { + const workflowApp = createMockApp({ + mode: AppModeEnum.WORKFLOW, + workflow: undefined, + }) + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + expect(screen.getByText('app.openInExplore')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('app.openInExplore')) + + expect(mockOpenAsyncWindow).not.toHaveBeenCalled() + expect(exploreService.fetchInstalledAppList).not.toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'app.notPublishedYet', + }) + }) + it('should handle case when installed_apps is empty array', async () => { (exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] }) @@ -1756,6 +1784,10 @@ describe('AppCard', () => { await waitFor(() => { expect(exploreService.fetchInstalledAppList).toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'app.notPublishedYet', + }) }) }) @@ -1944,6 +1976,31 @@ describe('AppCard', () => { }) }) + it('should keep open in explore visible for unpublished workflow apps while access check is pending', async () => { + mockUserCanAccessApp.result = false + mockUserCanAccessApp.isLoading = true + const workflowApp = createMockApp({ + mode: AppModeEnum.WORKFLOW, + workflow: undefined, + }) + + render() + + fireEvent.click(screen.getByTestId('dropdown-menu-trigger')) + await waitFor(() => { + expect(screen.getByText('app.openInExplore')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('app.openInExplore')) + + expect(mockOpenAsyncWindow).not.toHaveBeenCalled() + expect(exploreService.fetchInstalledAppList).not.toHaveBeenCalled() + expect(toastMocks.record).toHaveBeenCalledWith({ + type: 'error', + message: 'app.notPublishedYet', + }) + }) + it('should close access control modal when onClose is called', async () => { render() diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index f22bc28e931..3cb998febbf 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -90,6 +90,11 @@ const ACCESS_MODE_LABEL_KEYS = { [AccessMode.EXTERNAL_MEMBERS]: 'accessItemsDescription.external', } as const +const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.WORKFLOW, +]) + type AppCardProps = { app: App onlineUsers?: WorkflowOnlineUser[] @@ -103,6 +108,10 @@ type AppAccessModeIconProps = { const getAppResourceMaintainer = (app: App) => app.maintainer +function requiresPublishedWorkflowInExplore(app: App) { + return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode) +} + function AppAccessModeIcon({ accessMode }: AppAccessModeIconProps) { const { t } = useTranslation() @@ -182,12 +191,17 @@ function AppCardOperationsMenu({ 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('No app found in Explore') + throw new Error(t('notPublishedYet', { ns: 'app' })) }, { onError: (err) => { toast.error(`${err.message || err}`) @@ -272,10 +286,12 @@ function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) 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 && ( - !systemFeatures.webapp_auth.enabled + needsPublishBeforeExplore + || !systemFeatures.webapp_auth.enabled || (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result)) )