diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index a7b65f33fe..3ee5d52603 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -39,7 +39,9 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const getSigninUrl = useCallback(() => { const params = new URLSearchParams(searchParams) params.delete('message') - params.set('redirect_url', pathname) + const query = params.toString() + const fullPath = query ? `${pathname}?${query}` : pathname + params.set('redirect_url', fullPath) return `/webapp-signin?${params.toString()}` }, [searchParams, pathname]) diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx index 9afa0063dc..3dabb2a91e 100644 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx @@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
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 1fad833933..0dfb4347e4 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -20,6 +20,7 @@ const mockOpenAsyncWindow = vi.fn() const mockFetchInstalledAppList = vi.fn() const mockFetchAppDetailDirect = vi.fn() const mockToastError = vi.fn() +const mockWindowOpen = vi.fn() const mockInvalidateAppWorkflow = vi.fn() const sectionProps = vi.hoisted(() => ({ @@ -37,6 +38,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('ahooks', async () => { @@ -167,6 +169,12 @@ vi.mock('../sections', () => ({
+ {props.handleOpenRunConfig && ( + <> + + + + )}
) @@ -200,6 +208,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 () => { @@ -256,6 +268,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 keep workflow tool drawer mounted after closing the publish popover', () => { mockAppDetail = { ...mockAppDetail, diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 714cd3b7c3..453779504e 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -18,8 +18,32 @@ vi.mock('../publish-with-multiple-model', () => ({ })) 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 && ( + + )} +
), })) @@ -170,9 +194,25 @@ describe('app-publisher sections', () => { expect(render().container).toBeEmptyDOMElement() }) + it('should hide access control content when enabled is false', () => { + render( + , + ) + + expect(screen.queryByText('publishApp.title')).not.toBeInTheDocument() + expect(screen.queryByText('accessControlDialog.accessItems.anyone')).not.toBeInTheDocument() + }) + 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} missingStartNode={false} + published={false} publishedAt={Date.now()} + showBatchRunConfig + showRunConfig toolPublished workflowToolAvailable={false} workflowToolIsLoading={false} @@ -205,6 +250,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() @@ -222,9 +271,12 @@ describe('app-publisher sections', () => { disabledFunctionTooltip="disabled" handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} + handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} missingStartNode + published={false} publishedAt={Date.now()} toolPublished={false} workflowToolAvailable @@ -246,9 +298,12 @@ describe('app-publisher sections', () => { disabledFunctionButton={false} handleEmbed={handleEmbed} handleOpenInExplore={handleOpenInExplore} + handleOpenRunConfig={handleOpenRunConfig} + handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode missingStartNode={false} + published={false} publishedAt={undefined} toolPublished={false} workflowToolAvailable 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..2ca9e77abf 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,47 @@ 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) + }) + + it('should block action button clicks when disabled', () => { + const handleActionClick = vi.fn() + + render( + config, + onClick: handleActionClick, + }} + > + Disabled with action + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + expect(handleActionClick).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index a066233107..f5b2c80ae8 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,4 +1,6 @@ +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 { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' @@ -8,6 +10,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { + memo, use, useCallback, @@ -16,6 +19,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' @@ -111,6 +121,9 @@ const AppPublisher = ({ const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) + const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) const workflowStore = use(WorkflowContext) @@ -122,6 +135,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) @@ -231,6 +260,31 @@ 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]) const handlePublishToMarketplace = useCallback(async () => { if (!appDetail?.id || publishingToMarketplace) return @@ -377,10 +431,15 @@ const AppPublisher = ({ handleOpenChange(false) handleOpenInExplore() }} + handleOpenRunConfig={handleOpenWorkflowLaunchDialog} + handlePublish={handlePublish} hasHumanInputNode={hasHumanInputNode} hasTriggerNode={hasTriggerNode} missingStartNode={missingStartNode} + published={published} publishedAt={publishedAt} + showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)} + showRunConfig={hiddenLaunchVariables.length > 0} toolPublished={toolPublished} workflowToolAvailable={workflowToolAvailable} workflowToolIsLoading={workflowTool.isLoading} @@ -410,8 +469,19 @@ const AppPublisher = ({ onClose={() => setEmbeddingModalOpen(false)} appBaseUrl={appBaseURL} accessToken={accessToken} + hiddenInputs={hiddenLaunchVariables} /> {showAppAccessControl && { setShowAppAccessControl(false) }} />} + {workflowToolDrawerOpen && ( void handleOpenInExplore: () => void + handleOpenRunConfig?: (url: string) => void + handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise + published: boolean + showBatchRunConfig?: boolean + showRunConfig?: boolean workflowToolIsLoading: boolean workflowToolOutdated: boolean workflowToolIsCurrentWorkspaceManager: boolean @@ -253,10 +259,13 @@ export const PublisherActionsSection = ({ disabledFunctionTooltip, handleEmbed, handleOpenInExplore, + handleOpenRunConfig, hasHumanInputNode = false, hasTriggerNode = false, missingStartNode = false, publishedAt, + showBatchRunConfig = false, + showRunConfig = false, toolPublished, workflowToolAvailable = true, workflowToolIsLoading, @@ -280,6 +289,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' })} @@ -292,6 +308,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/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 7a63df3350..cdb1d17833 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -4,6 +4,29 @@ import { fireEvent, render, screen } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import ConfigModalFormFields from '../form-fields' +vi.mock('react-i18next', async () => { + const React = await import('react') + return { + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const ns = options?.ns as string | undefined + return ns ? `${ns}.${key}` : key + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record }) => ( + + {i18nKey} + {components?.docLink} + + ), + } +}) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path?: string) => `https://docs.example.com${path || ''}`, +})) + vi.mock('@/app/components/base/file-uploader', () => ({ FileUploaderInAttachmentWrapper: ({ onChange, @@ -74,6 +97,12 @@ vi.mock('@langgenius/dify-ui/select', async (importOriginal) => { } }) +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, +})) + vi.mock('../field', () => ({ default: ({ children, title }: { children: ReactNode, title: string }) => (
@@ -176,7 +205,18 @@ describe('ConfigModalFormFields', () => { expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta') }) - it('should wire file, json schema, and visibility controls', () => { + it('should wire file, json schema, and visibility controls', async () => { + const textInputProps = createBaseProps() + const textInputView = render() + expect(screen.getByText('variableConfig.hidden')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'variableConfig.hiddenDescription' })) + expect(await screen.findByText('variableConfig.hiddenDescription')).toBeInTheDocument() + const docLink = await screen.findByRole('link') + expect(docLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/nodes/user-input#hide-and-pre-fill-input-fields') + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + textInputView.unmount() + const singleFileProps = createBaseProps() singleFileProps.tempPayload = { ...singleFileProps.tempPayload, @@ -185,18 +225,20 @@ describe('ConfigModalFormFields', () => { allowed_file_extensions: [], allowed_file_upload_methods: ['remote_url'], } - render() + const singleFileView = render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() + expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument() fireEvent.click(screen.getByText('single-file-setting')) fireEvent.click(screen.getByText('upload-file')) fireEvent.click(screen.getAllByText('unchecked')[0]!) - fireEvent.click(screen.getAllByText('unchecked')[1]!) expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 }) expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({ fileId: 'file-1', })) expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true) - expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true) + expect(singleFileProps.payloadChangeHandlers.hide).not.toHaveBeenCalled() + singleFileView.unmount() const multiFileProps = createBaseProps() multiFileProps.tempPayload = { @@ -207,8 +249,9 @@ describe('ConfigModalFormFields', () => { allowed_file_upload_methods: ['remote_url'], } render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() fireEvent.click(screen.getByText('multi-file-setting')) - fireEvent.click(screen.getAllByText('upload-file')[1]!) + fireEvent.click(screen.getAllByText('upload-file')[0]!) expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 }) expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([ expect.objectContaining({ fileId: 'file-1' }), @@ -367,4 +410,23 @@ describe('ConfigModalFormFields', () => { expect(screen.getByRole('spinbutton')).toHaveValue(null) }) + + it('should disable hide checkbox when required is true and disable required when hide is true', () => { + const requiredProps = createBaseProps() + requiredProps.tempPayload = { ...requiredProps.tempPayload, type: InputVarType.textInput, required: true, hide: false } + const { unmount } = render() + + const buttons = screen.getAllByRole('button') + const hideButton = buttons.find(btn => btn.textContent === 'unchecked' && btn !== buttons[0]) + expect(hideButton).toBeDefined() + unmount() + + const hideProps = createBaseProps() + hideProps.tempPayload = { ...hideProps.tempPayload, type: InputVarType.textInput, required: false, hide: true } + render() + + const allButtons = screen.getAllByRole('button') + const checkedHideButton = allButtons.find(btn => btn.textContent === 'checked') + expect(checkedHideButton).toBeDefined() + }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index e6cb56f490..d32bcec755 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -25,6 +25,7 @@ vi.mock('../form-fields', () => ({ return (
{String(props.tempPayload.type)}
+
{String(props.tempPayload.hide)}
{String(props.tempPayload.label ?? '')}
{String(props.tempPayload.json_schema ?? '')}
{String(props.tempPayload.default ?? '')}
@@ -115,7 +116,7 @@ describe('ConfigModal logic', () => { }) it('should derive payload fields from mocked form-field callbacks', async () => { - renderConfigModal() + renderConfigModal(createPayload({ hide: true })) fireEvent.click(screen.getByTestId('valid-key-blur')) await waitFor(() => { @@ -138,6 +139,7 @@ describe('ConfigModal logic', () => { fireEvent.click(screen.getByTestId('type-change')) await waitFor(() => { expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile) + expect(screen.getByTestId('payload-hide')).toHaveTextContent('false') }) fireEvent.click(screen.getByTestId('file-payload-change')) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts index 1c00e1c5b2..2317868004 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts @@ -49,11 +49,13 @@ describe('config-modal utils', () => { const payload = createInputVar({ type: InputVarType.textInput, default: 'hello', + hide: true, }) const nextPayload = createPayloadForType(payload, InputVarType.multiFiles) expect(nextPayload.type).toBe(InputVarType.multiFiles) + expect(nextPayload.hide).toBe(false) expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length) expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types) expect(nextPayload.default).toBe('hello') @@ -249,6 +251,24 @@ describe('config-modal utils', () => { }) }) + it('should force file inputs to stay visible when saving', () => { + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.singleFile, + hide: true, + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: [], + }), + payload: createInputVar(), + checkVariableName: () => true, + t, + }) + + expect(result.payloadToSave).toEqual(expect.objectContaining({ + hide: false, + })) + }) + it('should stop validation when the variable name checker rejects the payload', () => { const result = validateConfigModalPayload({ tempPayload: createInputVar({ diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index 748108e19a..4bd938c3f6 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -13,14 +13,17 @@ import { SelectValue, } from '@langgenius/dify-ui/select' import * as React from 'react' +import { Trans } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import { Infotip } from '@/app/components/base/infotip' import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' import { TransferMethod } from '@/types/app' import ConfigSelect from '../config-select' import ConfigString from '../config-string' @@ -68,6 +71,9 @@ const ConfigModalFormFields: FC = ({ t, }) => { const { type, label, variable } = tempPayload + const isFileInput = [InputVarType.singleFile, InputVarType.multiFiles].includes(type) + const docLink = useDocLink() + const hiddenDescriptionAriaLabel = t('variableConfig.hiddenDescription', { ns: 'appDebug' }).replace(/<[^>]+>/g, '') return (
@@ -105,7 +111,7 @@ const ConfigModalFormFields: FC = ({ {type === InputVarType.textInput && ( onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -126,7 +132,7 @@ const ConfigModalFormFields: FC = ({ onPayloadChange('default')(e.target.value || undefined)} placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} /> @@ -186,7 +192,7 @@ const ConfigModalFormFields: FC = ({ )} - {[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && ( + {isFileInput && ( <> = ({ )}
- onPayloadChange('required')(!tempPayload.required)} /> + onPayloadChange('required')(!tempPayload.required)} /> {t('variableConfig.required', { ns: 'appDebug' })}
-
- onPayloadChange('hide')(!tempPayload.hide)} /> - {t('variableConfig.hide', { ns: 'appDebug' })} -
+ {!isFileInput && ( +
+ onPayloadChange('hide')(!tempPayload.hide)} /> +
+ {t('variableConfig.hidden', { ns: 'appDebug' })} + + + ), + }} + /> + +
+
+ )}
) } diff --git a/web/app/components/app/configuration/config-var/config-modal/utils.ts b/web/app/components/app/configuration/config-var/config-modal/utils.ts index fdc0ac3501..e24e4b6593 100644 --- a/web/app/components/app/configuration/config-var/config-modal/utils.ts +++ b/web/app/components/app/configuration/config-var/config-modal/utils.ts @@ -88,7 +88,9 @@ export const createPayloadForType = (payload: InputVar, type: InputVarType) => { draft.default = undefined if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { - (Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array).forEach((key) => { + draft.hide = false + const fileUploadSettingKeys = Object.keys(DEFAULT_FILE_UPLOAD_SETTING) as Array + fileUploadSettingKeys.forEach((key) => { if (key !== 'max_length') draft[key] = DEFAULT_FILE_UPLOAD_SETTING[key] as never }) @@ -158,38 +160,41 @@ export const validateConfigModalPayload = ({ checkVariableName, t, }: ValidateConfigModalPayloadOptions): ValidateConfigModalPayloadResult => { + const normalizedTempPayload = [InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type) + ? { ...tempPayload, hide: false } + : tempPayload const jsonSchemaValue = tempPayload.json_schema const schemaEmpty = isJsonSchemaEmpty(jsonSchemaValue) const normalizedJsonSchema = schemaEmpty ? undefined : jsonSchemaValue - const payloadToSave = tempPayload.type === InputVarType.jsonObject && schemaEmpty - ? { ...tempPayload, json_schema: undefined } - : tempPayload + const payloadToSave = normalizedTempPayload.type === InputVarType.jsonObject && schemaEmpty + ? { ...normalizedTempPayload, json_schema: undefined } + : normalizedTempPayload - const moreInfo = tempPayload.variable === payload?.variable + const moreInfo = normalizedTempPayload.variable === payload?.variable ? undefined : { type: ChangeType.changeVarName, - payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable }, + payload: { beforeKey: payload?.variable || '', afterKey: normalizedTempPayload.variable }, } - if (!checkVariableName(tempPayload.variable)) + if (!checkVariableName(normalizedTempPayload.variable)) return {} - if (!tempPayload.label) { + if (!normalizedTempPayload.label) { return { errorMessage: t('variableConfig.errorMsg.labelNameRequired', { ns: 'appDebug' }), } } - if (tempPayload.type === InputVarType.select) { - if (!tempPayload.options?.length) { + if (normalizedTempPayload.type === InputVarType.select) { + if (!normalizedTempPayload.options?.length) { return { errorMessage: t('variableConfig.errorMsg.atLeastOneOption', { ns: 'appDebug' }), } } const duplicated = new Set() - const hasRepeatedItem = tempPayload.options.some((option) => { + const hasRepeatedItem = normalizedTempPayload.options.some((option) => { if (duplicated.has(option)) return true @@ -204,8 +209,8 @@ export const validateConfigModalPayload = ({ } } - if ([InputVarType.singleFile, InputVarType.multiFiles].includes(tempPayload.type)) { - if (!tempPayload.allowed_file_types?.length) { + if ([InputVarType.singleFile, InputVarType.multiFiles].includes(normalizedTempPayload.type)) { + if (!normalizedTempPayload.allowed_file_types?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -214,7 +219,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) { + if (normalizedTempPayload.allowed_file_types.includes(SupportUploadFileTypes.custom) && !normalizedTempPayload.allowed_file_extensions?.length) { return { errorMessage: t('errorMsg.fieldRequired', { ns: 'workflow', @@ -224,7 +229,7 @@ export const validateConfigModalPayload = ({ } } - if (tempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { + if (normalizedTempPayload.type === InputVarType.jsonObject && !schemaEmpty && typeof normalizedJsonSchema === 'string') { try { const schema = JSON.parse(normalizedJsonSchema) if (schema?.type !== 'object') { diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx index 9820e15ad8..d3f83d5d9c 100644 --- a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -1,8 +1,38 @@ +import type { FormEvent } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, render, screen, within } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' -import { AppCardAccessControlSection, AppCardOperations, AppCardUrlSection, createAppCardOperations } from '../app-card-sections' +import { AppCardAccessControlSection, AppCardDialogs, AppCardOperations, AppCardUrlSection, createAppCardOperations, WorkflowLaunchDialog } from '../app-card-sections' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})) + +vi.mock('../settings', () => ({ + default: () =>
, +})) + +vi.mock('../embedded', () => ({ + default: () =>
, +})) + +vi.mock('../customize', () => ({ + default: () =>
, +})) + +vi.mock('../../app-access-control', () => ({ + default: ({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) => ( +
+ + +
+ ), +})) describe('app-card-sections', () => { const t = (key: string) => key @@ -52,6 +82,7 @@ describe('app-card-sections', () => { it('should render operation buttons and execute enabled actions', () => { const onLaunch = vi.fn() + const onLaunchConfig = vi.fn() const operations = createAppCardOperations({ operationKeys: ['launch', 'embedded'], t: t as never, @@ -68,12 +99,19 @@ describe('app-card-sections', () => { , ) fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + fireEvent.click(screen.getByRole('button', { name: /operation\.config/i })) expect(onLaunch).toHaveBeenCalledTimes(1) + expect(onLaunchConfig).toHaveBeenCalledTimes(1) expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() }) @@ -127,4 +165,127 @@ describe('app-card-sections', () => { fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i })) expect(onRegenerate).toHaveBeenCalledTimes(1) }) + + it('should disable all operations when triggerModeDisabled is true', () => { + const operations = createAppCardOperations({ + operationKeys: ['launch', 'settings'], + t: t as never, + runningStatus: true, + triggerModeDisabled: true, + onLaunch: vi.fn(), + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + expect(operations[0]!.disabled).toBe(true) + expect(operations[1]!.disabled).toBe(true) + }) + + it('should render WorkflowLaunchDialog and submit values', () => { + const onOpenChange = vi.fn() + const onValueChange = vi.fn() + const onSubmit = vi.fn((event: FormEvent) => { + event.preventDefault() + }) + + render( + , + ) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + fireEvent.submit(screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!) + expect(onSubmit).toHaveBeenCalled() + }) + + it('should return null for WorkflowLaunchDialog when no variables are provided', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('should render AppCardDialogs with all modals for web apps', () => { + const appInfo = { + id: 'app-1', + mode: AppModeEnum.CHAT, + enable_site: true, + enable_api: false, + site: { app_base_url: 'https://example.com', access_token: 'token-1' }, + api_base_url: 'https://api.example.com', + } as never + + render( + , + ) + + expect(screen.getByTestId('settings-modal')).toBeInTheDocument() + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + expect(screen.getByTestId('customize-modal')).toBeInTheDocument() + expect(screen.getByTestId('access-control')).toBeInTheDocument() + }) + + it('should return null for AppCardDialogs when not an app', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index fbfcdaf955..0a6d7f7dd7 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -1,9 +1,22 @@ import type { AppDetailResponse } from '@/models/app' -import { BlockEnum } from '@/app/components/workflow/types' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils' +import { + buildWorkflowLaunchUrl, + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getAppCardDisplayState, + getAppCardOperationKeys, + getAppHiddenLaunchVariables, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + getWorkflowHiddenStartVariables, + hasWorkflowStartNode, + isAppAccessConfigured, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' describe('app-card-utils', () => { const baseAppInfo = { @@ -33,6 +46,108 @@ describe('app-card-utils', () => { })).toBe(false) }) + it('should return hidden workflow start variables and their initial launch values', () => { + const hiddenVariables = getWorkflowHiddenStartVariables({ + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'visible', + label: 'Visible', + type: InputVarType.textInput, + hide: false, + required: false, + }, + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + default: 'prefilled', + required: false, + }, + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + default: true, + required: false, + }, + ], + }, + }], + }, + }) + + expect(hiddenVariables.map(variable => variable.variable)).toEqual(['secret', 'enabled']) + expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({ + secret: 'prefilled', + enabled: true, + }) + }) + + it('should return hidden advanced-chat launch variables from the workflow start node first', () => { + const hiddenVariables = getAppHiddenLaunchVariables({ + appInfo: { + ...baseAppInfo, + mode: AppModeEnum.ADVANCED_CHAT, + model_config: { + user_input_form: [ + { + 'text-input': { + label: 'Visible', + variable: 'visible', + required: true, + max_length: 48, + default: '', + hide: false, + }, + }, + { + checkbox: { + label: 'Hidden Toggle', + variable: 'hidden_toggle', + required: false, + default: true, + hide: true, + }, + }, + ], + }, + } as AppDetailResponse, + currentWorkflow: { + graph: { + nodes: [{ + data: { + type: BlockEnum.Start, + variables: [ + { + variable: 'start_secret', + label: 'Start Secret', + type: InputVarType.textInput, + hide: true, + default: 'from-start', + required: false, + }, + ], + }, + }], + }, + }, + }) + + expect(hiddenVariables).toEqual([ + expect.objectContaining({ + variable: 'start_secret', + type: InputVarType.textInput, + default: 'from-start', + }), + ]) + }) + it('should build the display state for a published web app', () => { const state = getAppCardDisplayState({ appInfo: baseAppInfo, @@ -104,4 +219,108 @@ describe('app-card-utils', () => { isCurrentWorkspaceEditor: false, })).toEqual(['launch', 'embedded', 'customize']) }) + + it('should build a workflow launch URL with serialized parameters', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'name', label: 'Name', type: InputVarType.textInput, hide: true, required: false }, + { variable: 'enabled', label: 'Enabled', type: InputVarType.checkbox, hide: true, required: false }, + ], + values: { name: 'Alice', enabled: true }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('name')).toBe('Alice') + expect(parsed.searchParams.get('enabled')).toBe('true') + }) + + it('should serialize checkbox false and empty string values in launch URL', async () => { + const url = await buildWorkflowLaunchUrl({ + accessibleUrl: 'https://example.com/app/workflow/token-1', + variables: [ + { variable: 'flag', label: 'Flag', type: InputVarType.checkbox, hide: true, required: false }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ], + values: { flag: false, empty: '' }, + }) + + const parsed = new URL(url) + expect(parsed.searchParams.get('flag')).toBe('false') + expect(parsed.searchParams.get('empty')).toBe('') + }) + + it('should generate an iframe snippet with the provided URL', () => { + const snippet = getEmbeddedIframeSnippet('https://example.com/chatbot/token-1') + expect(snippet).toContain('src="https://example.com/chatbot/token-1"') + expect(snippet).toContain('frameborder="0"') + expect(snippet).toContain('allow="microphone"') + }) + + it('should generate an embedded script snippet with inputs', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#FF0000', + isTestEnv: true, + inputValues: { name: 'Alice', count: '5' }, + }) + + expect(snippet).toContain('token: \'abc123\'') + expect(snippet).toContain('isDev: true') + expect(snippet).toContain('name: "Alice"') + expect(snippet).toContain('count: "5"') + expect(snippet).toContain('background-color: #FF0000') + }) + + it('should generate an embedded script snippet with empty inputs comment', () => { + const snippet = getEmbeddedScriptSnippet({ + url: 'https://example.com', + token: 'abc123', + primaryColor: '#1C64F2', + inputValues: {}, + }) + + expect(snippet).toContain('// You can define the inputs from the Start node here') + expect(snippet).not.toContain('isDev: true') + }) + + it('should compress and encode base64 using CompressionStream when available', async () => { + const result = await compressAndEncodeBase64('hello') + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('should fallback to plain base64 when CompressionStream is unavailable', async () => { + const original = globalThis.CompressionStream + // @ts-expect-error remove for test + delete globalThis.CompressionStream + + const result = await compressAndEncodeBase64('hello') + expect(result).toBe(btoa('hello')) + + globalThis.CompressionStream = original + }) + + it('should identify supported workflow launch input types', () => { + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.textInput, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.paragraph, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.select, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.number, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.checkbox, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.json, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.jsonObject, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.url, hide: true, required: false })).toBe(true) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.files, hide: true, required: false })).toBe(false) + expect(isWorkflowLaunchInputSupported({ variable: 'v', label: 'V', type: InputVarType.singleFile, hide: true, required: false })).toBe(false) + }) + + it('should coerce numeric defaults to string in createWorkflowLaunchInitialValues', () => { + const result = createWorkflowLaunchInitialValues([ + { variable: 'count', label: 'Count', type: InputVarType.number, hide: true, required: false, default: 42 }, + { variable: 'empty', label: 'Empty', type: InputVarType.textInput, hide: true, required: false }, + ]) + + expect(result).toEqual({ count: '42', empty: '' }) + }) }) diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 2f730ad278..a6bacce887 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' @@ -17,7 +18,7 @@ const mockSetAppDetail = vi.fn() const mockOnChangeStatus = vi.fn() const mockOnGenerateCode = vi.fn() -let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string } }> } } | null = null +let mockWorkflow: { graph?: { nodes?: Array<{ data?: { type?: string, variables?: Array> } }> } } | null = null let mockAccessSubjects: { groups?: unknown[], members?: unknown[] } = { groups: [], members: [] } let mockAppDetail: AppDetailResponse | undefined @@ -25,6 +26,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), + Trans: ({ i18nKey }: { i18nKey?: string }) => i18nKey ?? null, })) vi.mock('@/context/app-context', () => ({ @@ -164,6 +166,182 @@ describe('AppCard', () => { expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com${basePath}/chat/access-token`, '_blank') }) + it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/workflow/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.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}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`, + '_blank', + ) + }) + }) + + it('should open the chat web app directly when launch is clicked even with hidden inputs', () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByText('overview.appInfo.launch')) + + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token`, + '_blank', + ) + expect(screen.queryByText('overview.appInfo.workflowLaunchHiddenInputs.title')).not.toBeInTheDocument() + }) + + it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => { + mockWorkflow = { + graph: { + nodes: [{ + data: { + type: 'start', + variables: [ + { + variable: 'chat_secret', + label: 'Chat Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, + }], + }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.config' })) + + expect(screen.getByText('overview.appInfo.workflowLaunchHiddenInputs.title')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('Chat Secret'), { + target: { value: 'chat-secret' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'overview.appInfo.launch' })) + + await waitFor(() => { + expect(mockWindowOpen).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`, + '_blank', + ) + }) + }) + it('should show the access-control not-set badge when specific access has no subjects', () => { render( { }) it('should report refresh failures from access control updates', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) mockFetchAppDetailDirect.mockRejectedValueOnce(new Error('refresh failed')) render( diff --git a/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx new file mode 100644 index 0000000000..309df540a6 --- /dev/null +++ b/web/app/components/app/overview/__tests__/workflow-hidden-input-fields.spec.tsx @@ -0,0 +1,214 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' + +describe('WorkflowHiddenInputFields', () => { + const onValueChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render a text input with label and placeholder', () => { + render( + , + ) + + const input = screen.getByLabelText('Full Name') + expect(input).toHaveValue('Alice') + + fireEvent.change(input, { target: { value: 'Bob' } }) + expect(onValueChange).toHaveBeenCalledWith('name', 'Bob') + }) + + it('should render a number input for number-typed variables', () => { + render( + , + ) + + const input = screen.getByLabelText('Count') + expect(input).toHaveAttribute('type', 'number') + + fireEvent.change(input, { target: { value: '10' } }) + expect(onValueChange).toHaveBeenCalledWith('count', '10') + }) + + it('should render a checkbox input without a separate label element above', () => { + render( + , + ) + + const checkbox = screen.getByRole('checkbox') + expect(checkbox).toBeChecked() + expect(screen.getByText('Enable Feature')).toBeInTheDocument() + + fireEvent.click(checkbox) + expect(onValueChange).toHaveBeenCalledWith('enabled', false) + }) + + it('should render a select dropdown for select-typed variables', () => { + render( + , + ) + + expect(screen.getByRole('combobox', { name: 'Color' })).toBeInTheDocument() + }) + + it('should render a textarea for paragraph-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Description') + expect(textarea).toHaveValue('Hello world') + + fireEvent.change(textarea, { target: { value: 'Updated' } }) + expect(onValueChange).toHaveBeenCalledWith('description', 'Updated') + }) + + it('should render a textarea for json-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Config JSON') + expect(textarea).toHaveValue('{"key": "value"}') + }) + + it('should render a textarea for jsonObject-typed variables', () => { + render( + , + ) + + const textarea = screen.getByPlaceholderText('Schema') + expect(textarea).toHaveValue('{}') + }) + + it('should use the variable key as label when label is not a string', () => { + render( + , + ) + + expect(screen.getByText('my_var')).toBeInTheDocument() + }) + + it('should use the custom fieldIdPrefix for element ids', () => { + const { container } = render( + , + ) + + expect(container.querySelector('#custom-prefix-token')).toBeInTheDocument() + }) + + it('should render empty string for non-string fieldValue in text inputs', () => { + render( + , + ) + + const input = screen.getByLabelText('Flag') + expect(input).toHaveValue('') + }) +}) diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index 8fef355f34..8db5193f2d 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -1,7 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ import type { TFunction } from 'i18next' -import type { ComponentType, ReactNode } from 'react' -import type { OverviewOperationKey } from './app-card-utils' +import type { ComponentType, FormEvent, ReactNode } from 'react' +import type { + OverviewOperationKey, + WorkflowHiddenStartVariable, + WorkflowLaunchInputValue, +} from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -15,12 +19,19 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger, } from '@langgenius/dify-ui/tooltip' -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 { Trans } from 'react-i18next' import CopyFeedback from '@/app/components/base/copy-feedback' import Divider from '@/app/components/base/divider' import ShareQRCode from '@/app/components/base/qrcode' @@ -31,6 +42,7 @@ import CustomizeModal from './customize' import EmbeddedModal from './embedded' import SettingsModal from './settings' import style from './style.module.css' +import WorkflowHiddenInputFields from './workflow-hidden-input-fields' type AppInfo = AppDetailResponse & Partial @@ -50,6 +62,12 @@ type AppCardOperation = { onClick: () => void } +type LaunchConfigAction = { + label: string + disabled: boolean + onClick: () => void +} + const OPERATION_ICON_MAP: Record = { launch: RiExternalLinkLine, embedded: RiWindowLine, @@ -96,6 +114,65 @@ const MaybeTooltip = ({ ) } +export const WorkflowLaunchDialog = ({ + t, + open, + hiddenVariables, + unsupportedVariables, + values, + onOpenChange, + onValueChange, + onSubmit, +}: { + t: TFunction + open: boolean + hiddenVariables: WorkflowHiddenStartVariable[] + unsupportedVariables: WorkflowHiddenStartVariable[] + values: Record + onOpenChange: (open: boolean) => void + onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void + onSubmit: (event: FormEvent) => void +}) => { + if (!hiddenVariables.length && !unsupportedVariables.length) + return null + + return ( + + +
+ + {t('overview.appInfo.workflowLaunchHiddenInputs.title', { ns: 'appOverview' })} + + + }} + /> + +
+
+
+ +
+
+ + +
+
+
+
+ ) +} + export const createAppCardOperations = ({ operationKeys, t, @@ -251,20 +328,15 @@ export const AppCardAccessControlSection = ({ export const AppCardOperations = ({ t, operations, + launchConfigAction, }: { t: TFunction operations: AppCardOperation[] + launchConfigAction?: LaunchConfigAction }) => ( <> - {operations.map(({ key, label, Icon, disabled, onClick }) => ( -
- - ))} + ) + + if (key === 'launch' && launchConfigAction) { + return ( + + + + ) + } + + return ( + + ) + })} ) @@ -295,6 +431,7 @@ export const AppCardDialogs = ({ onCloseAccessControl, onSaveSiteConfig, onConfirmAccessControl, + hiddenInputs, }: { isApp: boolean appInfo: AppInfo @@ -310,6 +447,7 @@ export const AppCardDialogs = ({ onCloseAccessControl: () => void onSaveSiteConfig?: (params: ConfigParams) => Promise onConfirmAccessControl: () => Promise + hiddenInputs?: WorkflowHiddenStartVariable[] }) => { if (!isApp) return null @@ -329,6 +467,7 @@ export const AppCardDialogs = ({ onClose={onCloseEmbedded} appBaseUrl={appInfo.site?.app_base_url} accessToken={appInfo.site?.access_token} + hiddenInputs={hiddenInputs} /> type AppInfo = AppDetailResponse & Partial @@ -16,6 +23,7 @@ type WorkflowLike = { nodes?: Array<{ data?: { type?: string + variables?: InputVar[] } }> } @@ -42,10 +50,173 @@ const getCardAppMode = (mode: AppModeEnum) => { return (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : mode } +const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set([ + InputVarType.textInput, + InputVarType.paragraph, + InputVarType.select, + InputVarType.number, + InputVarType.checkbox, + InputVarType.json, + InputVarType.jsonObject, + InputVarType.url, +]) + +const coerceWorkflowLaunchDefaultValue = (variable: WorkflowHiddenStartVariable): WorkflowLaunchInputValue => { + if (variable.type === InputVarType.checkbox) { + if (typeof variable.default === 'boolean') + return variable.default + + return String(variable.default).toLowerCase() === 'true' + } + + if (typeof variable.default === 'number') + return String(variable.default) + + return String(variable.default ?? '') +} + export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => { return currentWorkflow?.graph?.nodes?.some(node => node.data?.type === BlockEnum.Start) ?? false } +export const getWorkflowHiddenStartVariables = (currentWorkflow: WorkflowLike): WorkflowHiddenStartVariable[] => { + const startNode = currentWorkflow?.graph?.nodes?.find(node => node.data?.type === BlockEnum.Start) + return (startNode?.data?.variables ?? []).filter(variable => variable.hide === true) +} + +export const getAppHiddenLaunchVariables = ({ + appInfo, + currentWorkflow, +}: { + appInfo: AppInfo + currentWorkflow: WorkflowLike +}) => { + if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode)) + return getWorkflowHiddenStartVariables(currentWorkflow) +} + +export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => { + return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type) +} + +export const createWorkflowLaunchInitialValues = (variables: WorkflowHiddenStartVariable[]) => { + return variables.reduce>((acc, variable) => { + acc[variable.variable] = coerceWorkflowLaunchDefaultValue(variable) + return acc + }, {}) +} + +export const buildWorkflowLaunchUrl = async ({ + accessibleUrl, + variables, + values, +}: { + accessibleUrl: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const targetUrl = new URL(accessibleUrl, window.location.origin) + variables.forEach((variable) => { + const rawValue = values[variable.variable] + const serializedValue = variable.type === InputVarType.checkbox + ? String(Boolean(rawValue)) + : String(rawValue ?? '') + + targetUrl.searchParams.set(variable.variable, serializedValue) + }) + + return targetUrl.toString() +} + +export const getEmbeddedIframeSnippet = (iframeUrl: string) => + `` + +const getScriptInputsContent = (values: Record) => { + const entries = Object.entries(values) + + if (!entries.length) { + return `{ + // You can define the inputs from the Start node here + // key is the variable name + // e.g. + // name: "NAME" + }` + } + + return `{ +${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\n')} + }` +} + +export const getEmbeddedScriptSnippet = ({ + url, + token, + primaryColor, + isTestEnv, + inputValues, +}: { + url: string + token: string + primaryColor: string + isTestEnv?: boolean + inputValues: Record +}) => + ` + +` + +export const getChromePluginContent = (iframeUrl: string) => `ChatBot URL: ${iframeUrl}` + +export const compressAndEncodeBase64 = async (input: string) => { + const uint8Array = new TextEncoder().encode(input) + if (typeof CompressionStream === 'undefined') + return btoa(String.fromCharCode(...uint8Array)) + + const compressedStream = new Response( + new Blob([uint8Array]) + .stream() + .pipeThrough(new CompressionStream('gzip')), + ).arrayBuffer() + const compressedUint8Array = new Uint8Array(await compressedStream) + return btoa(String.fromCharCode(...compressedUint8Array)) +} + export const getAppCardDisplayState = ({ appInfo, cardType, diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index b7ec4a2d81..9b1fc3a032 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -1,4 +1,5 @@ 'use client' +import type { WorkflowLaunchInputValue } from './app-card-utils' import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' @@ -28,11 +29,16 @@ import { AppCardOperations, AppCardUrlSection, createAppCardOperations, + WorkflowLaunchDialog, } from './app-card-sections' import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, getAppCardDisplayState, getAppCardOperationKeys, + getAppHiddenLaunchVariables, isAppAccessConfigured, + isWorkflowLaunchInputSupported, } from './app-card-utils' export type IAppCardProps = { @@ -63,7 +69,8 @@ function AppCard({ const router = useRouter() const pathname = usePathname() const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() - const { data: currentWorkflow } = useAppWorkflow(appInfo.mode === AppModeEnum.WORKFLOW ? appInfo.id : '') + const shouldFetchWorkflow = appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT + const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '') const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(state => state.setAppDetail) @@ -73,6 +80,8 @@ function AppCard({ const [genLoading, setGenLoading] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false) + const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false) + const [workflowLaunchValues, setWorkflowLaunchValues] = useState>({}) const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appAccessSubjects } = useAppWhiteListSubjects( @@ -98,6 +107,25 @@ function AppCard({ () => isAppAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail], ) + const hiddenLaunchVariables = useMemo( + () => getAppHiddenLaunchVariables({ + appInfo, + currentWorkflow, + }) || [], + [appInfo, currentWorkflow], + ) + const supportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), + [hiddenLaunchVariables], + ) + const unsupportedWorkflowLaunchVariables = useMemo( + () => hiddenLaunchVariables.filter(variable => !isWorkflowLaunchInputSupported(variable)), + [hiddenLaunchVariables], + ) + const initialWorkflowLaunchValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), + [supportedWorkflowLaunchVariables], + ) const onGenCode = async () => { if (!onGenerateCode) @@ -139,6 +167,31 @@ function AppCard({ window.open(cardState.accessibleUrl, '_blank') }, [cardState.accessibleUrl]) + const handleOpenWorkflowLaunchDialog = useCallback(() => { + setWorkflowLaunchValues(initialWorkflowLaunchValues) + setShowWorkflowLaunchDialog(true) + }, [initialWorkflowLaunchValues]) + + const handleWorkflowLaunchValueChange = useCallback((variable: string, value: WorkflowLaunchInputValue) => { + setWorkflowLaunchValues(prev => ({ + ...prev, + [variable]: value, + })) + }, []) + + const handleWorkflowLaunchConfirm = useCallback(async (event: React.FormEvent) => { + event.preventDefault() + + const targetUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: cardState.accessibleUrl, + variables: supportedWorkflowLaunchVariables, + values: workflowLaunchValues, + }) + + window.open(targetUrl, '_blank') + setShowWorkflowLaunchDialog(false) + }, [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues]) + const handleOpenCustomize = useCallback(() => { setShowCustomizeModal(true) }, []) @@ -304,7 +357,17 @@ function AppCard({ {!cardState.isMinimalState && (
{!isApp && } - + 0 + ? { + label: t('operation.config', { ns: 'common' }), + disabled: triggerModeDisabled || !cardState.runningStatus, + onClick: handleOpenWorkflowLaunchDialog, + } + : undefined} + />
)}
@@ -323,6 +386,17 @@ function AppCard({ onCloseAccessControl={() => setShowAccessControl(false)} onSaveSiteConfig={onSaveSiteConfig} onConfirmAccessControl={handleAccessControlUpdate} + hiddenInputs={hiddenLaunchVariables} + /> +
) diff --git a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx index 0a843c26fd..a6e391cb0e 100644 --- a/web/app/components/app/overview/embedded/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/embedded/__tests__/index.spec.tsx @@ -1,10 +1,11 @@ import type { SiteInfo } from '@/models/share' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import copy from 'copy-to-clipboard' import * as React from 'react' - import { act } from 'react' -import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { InputVarType } from '@/app/components/workflow/types' import Embedded from '../index' vi.mock('../style.module.css', () => ({ @@ -46,6 +47,7 @@ vi.mock('@/context/app-context', () => ({ })) const mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null) const mockedCopy = vi.mocked(copy) +const originalCompressionStream = globalThis.CompressionStream const siteInfo: SiteInfo = { title: 'test site', @@ -70,6 +72,22 @@ const getCopyButton = () => { } describe('Embedded', () => { + beforeAll(() => { + class MockCompressionStream { + readable: ReadableStream + writable: WritableStream + + constructor() { + const transformStream = new TransformStream() + this.readable = transformStream.readable + this.writable = transformStream.writable + } + } + + // @ts-expect-error test polyfill + globalThis.CompressionStream = MockCompressionStream + }) + afterEach(() => { vi.clearAllMocks() mockWindowOpen.mockClear() @@ -77,6 +95,7 @@ describe('Embedded', () => { afterAll(() => { mockWindowOpen.mockRestore() + globalThis.CompressionStream = originalCompressionStream }) it('builds theme and copies iframe snippet', async () => { @@ -84,14 +103,20 @@ describe('Embedded', () => { render() }) + await waitFor(() => { + expect(screen.getByText((content, node) => node?.tagName.toLowerCase() === 'pre' && content.includes('/chatbot/token'))).toBeInTheDocument() + }) + const actionButton = getCopyButton() const innerDiv = actionButton.querySelector('div') - act(() => { + await act(async () => { fireEvent.click(innerDiv ?? actionButton) }) expect(mockThemeBuilder.buildTheme).toHaveBeenCalledWith(siteInfo.chat_color_theme, siteInfo.chat_color_theme_inverted) - expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + }) }) it('opens chrome plugin store link when chrome option selected', async () => { @@ -116,4 +141,106 @@ describe('Embedded', () => { 'noopener,noreferrer', ) }) + + it('keeps hidden inputs collapsed by default and updates iframe and script content when values change', async () => { + render( + , + ) + + expect(screen.queryByLabelText('Secret')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByText('appOverview.overview.appInfo.embedded.hiddenInputs.title').closest('button')!) + }) + + await waitFor(() => { + expect(screen.getByLabelText('Secret')).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.change(screen.getByLabelText('Secret'), { + target: { value: 'top-secret' }, + }) + }) + + expect(document.querySelector('pre')?.textContent ?? '').toContain('/chatbot/token') + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('/chatbot/token?secret=dG9wLXNlY3JldA%3D%3D') + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('secret: "top-secret"') + }) + }) + + it('copies script content when scripts option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[1]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('token: \'token\'') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('token: \'token\'')) + }) + }) + + it('copies chrome plugin URL (without prefix) when chromePlugin option is selected', async () => { + await act(async () => { + render() + }) + + const optionButtons = document.body.querySelectorAll('[class*="option"]') + act(() => { + fireEvent.click(optionButtons[2]!) + }) + + await waitFor(() => { + const codeBlock = document.querySelector('pre') + expect(codeBlock?.textContent ?? '').toContain('ChatBot URL:') + }) + + const actionButton = getCopyButton() + const innerDiv = actionButton.querySelector('div') + await act(async () => { + fireEvent.click(innerDiv ?? actionButton) + }) + + await waitFor(() => { + expect(mockedCopy).toHaveBeenCalledWith(expect.stringContaining('/chatbot/token')) + expect(mockedCopy).not.toHaveBeenCalledWith(expect.stringContaining('ChatBot URL:')) + }) + }) }) diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index 12203178f1..112848760b 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -1,88 +1,46 @@ +import type { MutableRefObject } from 'react' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils' import type { SiteInfo } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { + RiArrowDownSLine, + RiArrowRightSLine, +} from '@remixicon/react' import copy from 'copy-to-clipboard' -import * as React from 'react' -import { useState } from 'react' +import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' -import { IS_CE_EDITION } from '@/config' +import { InputVarType } from '@/app/components/workflow/types' import { useAppContext } from '@/context/app-context' import { basePath } from '@/utils/var' +import { + compressAndEncodeBase64, + createWorkflowLaunchInitialValues, + getChromePluginContent, + getEmbeddedIframeSnippet, + getEmbeddedScriptSnippet, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' import style from './style.module.css' type Props = { siteInfo?: SiteInfo isShow: boolean onClose: () => void - accessToken: string - appBaseUrl: string + accessToken?: string + appBaseUrl?: string + hiddenInputs?: WorkflowHiddenStartVariable[] className?: string } -const OPTION_MAP = { - iframe: { - getContent: (url: string, token: string) => - ``, - }, - scripts: { - getContent: (url: string, token: string, primaryColor: string, isTestEnv?: boolean) => - ` - -`, - }, - chromePlugin: { - getContent: (url: string, token: string) => `ChatBot URL: ${url}${basePath}/chatbot/${token}`, - }, -} +const OPTION_KEYS = ['iframe', 'scripts', 'chromePlugin'] as const const prefixEmbedded = 'overview.appInfo.embedded' -type Option = keyof typeof OPTION_MAP - -const OPTIONS: Option[] = ['iframe', 'scripts', 'chromePlugin'] +type Option = typeof OPTION_KEYS[number] const optionIconClassName: Record = { iframe: style.iframeIcon!, @@ -90,38 +48,274 @@ const optionIconClassName: Record = { chromePlugin: style.chromePluginIcon!, } -const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, className }: Props) => { +const getSerializedHiddenInputValue = ( + variable: WorkflowHiddenStartVariable, + values: Record, +) => { + const rawValue = values[variable.variable] + if (variable.type === InputVarType.checkbox) + return String(Boolean(rawValue)) + + return String(rawValue ?? '') +} + +const buildEmbeddedIframeUrl = async ({ + appBaseUrl, + accessToken, + variables, + values, +}: { + appBaseUrl: string + accessToken: string + variables: WorkflowHiddenStartVariable[] + values: Record +}) => { + const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin) + + await Promise.all(variables.map(async (variable) => { + iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values))) + })) + + return iframeUrl.toString() +} + +const AsyncEmbeddedOptionContent = ({ + option, + iframeUrlPromise, + latestResolvedIframeUrlRef, +}: { + option: Option + iframeUrlPromise: Promise + latestResolvedIframeUrlRef: MutableRefObject +}) => { + const iframeUrl = use(iframeUrlPromise) + latestResolvedIframeUrlRef.current = iframeUrl + + if (option === 'chromePlugin') + return getChromePluginContent(iframeUrl) + + return getEmbeddedIframeSnippet(iframeUrl) +} + +const EmbeddedContent = ({ + siteInfo, + appBaseUrl, + accessToken, + hiddenInputs, +}: Required> & Pick) => { const { t } = useTranslation() + const supportedHiddenInputs = useMemo( + () => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported), + [hiddenInputs], + ) + const initialHiddenInputValues = useMemo( + () => createWorkflowLaunchInitialValues(supportedHiddenInputs), + [supportedHiddenInputs], + ) const [option, setOption] = useState