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 e97efaa525..059653f272 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -15,6 +15,7 @@ const mockOpenAsyncWindow = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() +const mockWindowOpen = vi.fn() const sectionProps = vi.hoisted(() => ({ summary: null as null | Record, @@ -158,6 +159,12 @@ vi.mock('../sections', () => ({
+ {props.handleOpenRunConfig && ( + <> + + + + )}
) }, @@ -190,6 +197,10 @@ describe('AppPublisher', () => { mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { await resolver() }) + Object.defineProperty(window, 'open', { + writable: true, + value: mockWindowOpen, + }) }) it('should open the publish popover and refetch access permission data', async () => { @@ -246,6 +257,75 @@ describe('AppPublisher', () => { expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() }) + it('should collect hidden inputs before opening published run links from config actions', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-run-config')) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open batch run config links with the configured hidden inputs', async () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-batch-run-config')) + + fireEvent.change(screen.getByLabelText('Batch Secret'), { + target: { value: 'batch-value' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`, + '_blank', + ) + }) + }) + it('should close embedded and access control panels through child callbacks', async () => { render( ({ })) vi.mock('../suggested-action', () => ({ - default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => ( - + default: ({ + children, + onClick, + link, + disabled, + actionButton, + }: { + children: ReactNode + onClick?: () => void + link?: string + disabled?: boolean + actionButton?: { ariaLabel: string, onClick: () => void } + }) => ( +
+ + {actionButton && ( + + )} +
), })) @@ -173,6 +197,7 @@ describe('app-publisher sections', () => { it('should render workflow actions, batch run links, and workflow tool configuration', () => { const handleOpenInExplore = vi.fn() const handleEmbed = vi.fn() + const handleOpenRunConfig = vi.fn() const { rerender } = render( { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - inputs={[]} + inputs={[{ + variable: 'secret', + label: 'Secret', + type: 'text-input', + required: false, + hide: true, + } as any]} missingStartNode={false} onRefreshData={vi.fn()} outputs={[]} published={true} publishedAt={Date.now()} + showBatchRunConfig + showRunConfig toolPublished workflowToolAvailable={false} workflowToolMessage="workflow-disabled" @@ -206,6 +240,10 @@ describe('app-publisher sections', () => { ) expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[0]) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app') + fireEvent.click(screen.getAllByRole('button', { name: 'operation.config' })[1]) + expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch') fireEvent.click(screen.getByText('common.openInExplore')) expect(handleOpenInExplore).toHaveBeenCalled() expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument() @@ -223,6 +261,7 @@ describe('app-publisher sections', () => { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} @@ -248,6 +287,7 @@ describe('app-publisher sections', () => { disabledFunctionButton={false} handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode diff --git a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx index ea199dfb78..72256dbbbf 100644 --- a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx @@ -46,4 +46,26 @@ describe('SuggestedAction', () => { expect(handleClick).toHaveBeenCalledTimes(1) }) + + it('should render and trigger the trailing action button when configured', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Configurable action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + + expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute('href', 'https://example.com/docs') + expect(handleActionClick).toHaveBeenCalledTimes(1) + }) }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 9c50a98124..f5a7cf5057 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,8 +1,11 @@ +import type { FormEvent } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '@/app/components/app/overview/app-card-utils' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' import { useKeyPress } from 'ahooks' import { + memo, useCallback, useEffect, @@ -10,6 +13,13 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' +import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections' +import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, + isWorkflowLaunchInputSupported, + +} from '@/app/components/app/overview/app-card-utils' import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' @@ -96,6 +106,9 @@ const AppPublisher = ({ const [showAppAccessControl, setShowAppAccessControl] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) + const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) @@ -105,6 +118,22 @@ const AppPublisher = ({ const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) + const hiddenLaunchVariables = useMemo( + () => (inputs ?? []).filter(input => input.hide === true), + [inputs], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) @@ -192,6 +221,32 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) + const handleOpenWorkflowLaunchDialog = useCallback((targetUrl: string) => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setWorkflowLaunchTargetUrl(targetUrl) + setWorkflowLaunchDialogOpen(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: workflowLaunchTargetUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setWorkflowLaunchDialogOpen(false) + }, [supportedWorkflowLaunchVariables, workflowLaunchTargetUrl, workflowLaunchValues]) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { e.preventDefault() if (publishDisabled || published) @@ -265,6 +320,7 @@ const AppPublisher = ({ handleTrigger() }} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenWorkflowLaunchDialog} handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} @@ -274,6 +330,8 @@ const AppPublisher = ({ outputs={outputs} published={published} publishedAt={publishedAt} + showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)} + showRunConfig={hiddenLaunchVariables.length > 0} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailable} workflowToolMessage={workflowToolMessage} @@ -288,6 +346,16 @@ const AppPublisher = ({ accessToken={accessToken} /> {showAppAccessControl && { setShowAppAccessControl(false) }} />} + ) diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx index d4864a3763..b0bf37dfa0 100644 --- a/web/app/components/app/app-publisher/sections.tsx +++ b/web/app/components/app/app-publisher/sections.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react' import type { ModelAndParameter } from '../configuration/debug/types' import type { AppPublisherProps } from './index' import type { PublishWorkflowParams } from '@/types/workflow' +import { RiSettings2Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' @@ -67,8 +68,11 @@ type ActionsSectionProps = Pick void handleOpenInExplore: () => void + handleOpenRunConfig?: (url: string) => void handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise published: boolean + showBatchRunConfig?: boolean + showRunConfig?: boolean workflowToolMessage?: string } @@ -256,6 +260,7 @@ export const PublisherActionsSection = ({ disabledFunctionTooltip, handleEmbed, handleOpenInExplore, + handleOpenRunConfig, handlePublish, hasHumanInputNode = false, hasTriggerNode = false, @@ -265,6 +270,8 @@ export const PublisherActionsSection = ({ outputs, published, publishedAt, + showBatchRunConfig = false, + showRunConfig = false, toolPublished, workflowToolAvailable = true, workflowToolMessage, @@ -284,6 +291,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={appURL} icon={} + actionButton={showRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(appURL), + } + : undefined} > {t('common.runApp', { ns: 'workflow' })} @@ -296,6 +310,13 @@ export const PublisherActionsSection = ({ disabled={disabledFunctionButton} link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} icon={} + actionButton={showBatchRunConfig + ? { + ariaLabel: t('operation.config', { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), + } + : undefined} > {t('common.batchRunApp', { ns: 'workflow' })} diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index db13364eb9..c1cec6f819 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -1,33 +1,93 @@ -import type { HTMLProps, PropsWithChildren } from 'react' +import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowRightUpLine } from '@remixicon/react' +type SuggestedActionButton = { + ariaLabel: string + icon: React.ReactNode + onClick: (event: ReactMouseEvent) => void +} + type SuggestedActionProps = PropsWithChildren & { icon?: React.ReactNode link?: string disabled?: boolean + actionButton?: SuggestedActionButton }> -const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => { - const handleClick = (e: React.MouseEvent) => { - if (disabled) +const SuggestedAction = ({ + icon, + link, + disabled, + children, + className, + onClick, + actionButton, + ...props +}: SuggestedActionProps) => { + const handleClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() return - onClick?.(e) + } + + onClick?.(event) } - return ( + + const handleActionClick = (event: ReactMouseEvent) => { + if (disabled) { + event.preventDefault() + return + } + + actionButton?.onClick(event) + } + + const mainAction = ( -
{icon}
+
{icon}
{children}
- +
) + + if (!actionButton) + return mainAction + + return ( +
+ {mainAction} + +
+ ) } export default SuggestedAction diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index f68908a3fa..9bee1d7121 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -9,7 +9,7 @@ import type { import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' -import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' +import { RiArrowRightSLine, RiBookOpenLine, RiBuildingLine, RiEqualizer2Line, RiExternalLinkLine, RiGlobalLine, RiLockLine, RiPaintBrushLine, RiSettings2Line, RiVerifiedBadgeLine, RiWindowLine } from '@remixicon/react' import CopyFeedback from '@/app/components/base/copy-feedback' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' @@ -474,7 +474,7 @@ export const AppCardOperations = ({ } }} > - +